Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ff7e2f50 | ||
|
|
45cb237f74 | ||
|
|
bd711108f9 | ||
|
|
6b4d808d39 | ||
|
|
c7d8ddf98d | ||
|
|
754b0df056 | ||
|
|
6be124777f | ||
|
|
355a1460d9 | ||
|
|
c6edc84e40 | ||
|
|
f497047fff | ||
|
|
a9de7d3ca4 | ||
|
|
1c200d883f | ||
|
|
d268524084 | ||
|
|
ed4673e7d2 | ||
|
|
de723914a5 | ||
|
|
649801e479 | ||
|
|
49c5bea4f3 | ||
|
|
6707e56f9c | ||
|
|
2ba1dcdda4 | ||
|
|
1637ae16c7 | ||
|
|
eee19d7347 | ||
|
|
19f7b59ffb | ||
|
|
c4952d0207 | ||
|
|
ecf2684f58 | ||
|
|
9e9b898dd2 | ||
|
|
17f692e7f1 | ||
|
|
574d9aa2f7 | ||
|
|
1aaaef0274 | ||
|
|
00c037b5be | ||
|
|
9e15115ad4 | ||
|
|
25bf3d12f2 | ||
|
|
f78cc5c846 | ||
|
|
72fe795f3f | ||
|
|
0e892c7d75 | ||
|
|
8995bf65d6 | ||
|
|
91af2bc3b8 | ||
|
|
e2e8b3bf52 | ||
|
|
a652b90fd4 | ||
|
|
7a868ddf39 | ||
|
|
89d7c5f11b | ||
|
|
efb61cae02 | ||
|
|
fb88c6ace9 | ||
|
|
5258959a14 | ||
|
|
c515fc1001 | ||
|
|
7692048cf4 | ||
|
|
426810a776 | ||
|
|
f253841cf7 | ||
|
|
da2522c8ca | ||
|
|
43715e28b0 | ||
|
|
d11aa0f030 | ||
|
|
41396426d0 | ||
|
|
6946929e0b | ||
|
|
6756ead38a | ||
|
|
4c44a2b632 | ||
|
|
c0468754a4 | ||
|
|
e06b1d68ff | ||
|
|
b101d5b12b | ||
|
|
acc5dc8a3c | ||
|
|
592749c986 | ||
|
|
9b6174e03e | ||
|
|
f73fa08e49 | ||
|
|
bb5e5a40d9 | ||
|
|
e3b0c9b1ed | ||
|
|
cc1f0a47e3 | ||
|
|
b0c9160ed5 | ||
|
|
0b544a86b7 | ||
|
|
d71031d097 | ||
|
|
46192d68ba | ||
|
|
eeb1782f7a | ||
|
|
4d0e194861 | ||
|
|
3046ff8e77 | ||
|
|
ad33a46883 | ||
|
|
bbca507792 | ||
|
|
97390829dd | ||
|
|
7828089081 | ||
|
|
a2908d2cbf | ||
|
|
2f935586c8 | ||
|
|
af000a8dfa | ||
|
|
89c6b737c0 | ||
|
|
bfe0e310fe | ||
|
|
f19a3ccfa5 | ||
|
|
89c5038446 | ||
|
|
4f915e4e2c | ||
|
|
ec03b7cca3 | ||
|
|
e0544579d2 | ||
|
|
fa1ab411e3 | ||
|
|
b247ef1266 | ||
|
|
c7ee0491b2 | ||
|
|
ce43280c11 | ||
|
|
74ca40c197 | ||
|
|
cfaa673863 | ||
|
|
3bc6c31a2d | ||
|
|
d06aa618e5 | ||
|
|
9bedec7ed2 | ||
|
|
383aeefaf6 | ||
|
|
a5bede3a19 | ||
|
|
bbf66e23d6 | ||
|
|
0bc5345cf5 | ||
|
|
5e168c92cf | ||
|
|
725577103d | ||
|
|
f762117d4e | ||
|
|
750b6c04d6 | ||
|
|
59e51c348a | ||
|
|
a056a9abfb | ||
|
|
33ae780103 | ||
|
|
daf56514f7 | ||
|
|
8bcbceb971 | ||
|
|
df01f36442 | ||
|
|
b0024aa669 | ||
|
|
7b7aeadbbe | ||
|
|
94ad422a9f | ||
|
|
4f1ee37508 | ||
|
|
fec0347cd6 | ||
|
|
93318f4a83 | ||
|
|
a14fd0250c | ||
|
|
c99e228669 | ||
|
|
95d495f290 | ||
|
|
4bf300d862 | ||
|
|
1a1fc531f5 | ||
|
|
9fc570607f | ||
|
|
4851d19141 | ||
|
|
42fb25d150 | ||
|
|
416ad6571d | ||
|
|
9a119fbd64 | ||
|
|
c5decb2f90 | ||
|
|
fae2a4f5f0 | ||
|
|
d25b106e4f | ||
|
|
9f78e51ae7 | ||
|
|
d2752d8b5b | ||
|
|
8ecbff391c | ||
|
|
d259864a2b | ||
|
|
408098bdc1 | ||
|
|
658ec1676c | ||
|
|
e36d3b3474 | ||
|
|
0b9952c58d | ||
|
|
56af1ea091 | ||
|
|
ea5859b92b | ||
|
|
19f2ed5c69 | ||
|
|
efbaf7a49d | ||
|
|
374a9e9b13 | ||
|
|
d7d85c9e67 | ||
|
|
0fa982fe91 | ||
|
|
c4fb1bbd3e | ||
|
|
26d7d8f946 | ||
|
|
05ac342c4b | ||
|
|
5e491aef8f | ||
|
|
202187d5e2 | ||
|
|
13877b1c3a | ||
|
|
0e72e89ba3 | ||
|
|
f1b68271cc | ||
|
|
83efff21cd | ||
|
|
e6a4b35921 | ||
|
|
cc2d97ddba | ||
|
|
b78dd19cf9 | ||
|
|
1f0a75f836 | ||
|
|
16202c83a3 | ||
|
|
f4cc76c77d | ||
|
|
9fef6a9c43 | ||
|
|
810985b03a | ||
|
|
02633c6bd3 | ||
|
|
eb9416aa16 | ||
|
|
65b64af213 | ||
|
|
f1d160a481 | ||
|
|
f8c7f012a1 | ||
|
|
45618a55e6 | ||
|
|
c49583836b | ||
|
|
9dc8dc7065 | ||
|
|
f978e306cc | ||
|
|
aec852f971 | ||
|
|
143f781064 | ||
|
|
953b422295 | ||
|
|
da1a0f1299 | ||
|
|
bc7d19cfd8 | ||
|
|
df3122090f | ||
|
|
713fdf6188 | ||
|
|
70e21b58b4 | ||
|
|
18ebba1bb2 | ||
|
|
937404e6df | ||
|
|
88e155dd23 | ||
|
|
2e2cea0973 | ||
|
|
9b8c13a8b6 | ||
|
|
8238cc9f41 | ||
|
|
e59c4f30b8 | ||
|
|
fd7ef5edc2 | ||
|
|
a8e1acec09 | ||
|
|
31eb10985e | ||
|
|
1436b62a80 | ||
|
|
ec6a27635b | ||
|
|
1727744691 | ||
|
|
afdd47b5a5 | ||
|
|
d968e8e551 | ||
|
|
c649d1a762 | ||
|
|
a1f5d97345 | ||
|
|
58062515a5 | ||
|
|
5614b508f2 | ||
|
|
5e003a41b1 | ||
|
|
4eaeb1dd4a | ||
|
|
84471bd6f0 | ||
|
|
c8e3ac21c2 | ||
|
|
c38892b7cf | ||
|
|
1a0a5324f0 | ||
|
|
c1e9e9e0d6 | ||
|
|
57c93243a0 | ||
|
|
4259336e6d | ||
|
|
a18ce2e54d | ||
|
|
a0dc5d6183 | ||
|
|
5149f6808f | ||
|
|
918db33a8b | ||
|
|
95cbde9187 | ||
|
|
5e194393ff | ||
|
|
3da572a76a | ||
|
|
a116cba8ba | ||
|
|
fe5952fe14 | ||
|
|
2ab45ffd90 | ||
|
|
7c4932154c | ||
|
|
e2dbaa7c78 | ||
|
|
11a0dbc84a | ||
|
|
ee441643dd | ||
|
|
4c5affba99 | ||
|
|
c8148ef2cc | ||
|
|
b89740bad6 | ||
|
|
5c0d2b274c | ||
|
|
0489cd67c8 | ||
|
|
15d495e56e | ||
|
|
28b775198d | ||
|
|
e976bd5fc9 | ||
|
|
86355413fc | ||
|
|
c650afa6eb | ||
|
|
14f558facf | ||
|
|
0257d1f084 | ||
|
|
b1b4730536 | ||
|
|
f732dcd2ba | ||
|
|
8406355e7f | ||
|
|
3c5f40648e | ||
|
|
794e168008 | ||
|
|
35548e4780 | ||
|
|
2a056cc5d0 | ||
|
|
3baadb99ce | ||
|
|
fcb8b2c782 | ||
|
|
c74f1eeb26 | ||
|
|
933615003c | ||
|
|
fc49f3dc7a | ||
|
|
8fb0ecb9ba | ||
|
|
ce6c32bf53 | ||
|
|
7dbef98dd0 | ||
|
|
773804ee80 | ||
|
|
1df56cc99b | ||
|
|
0606762c29 | ||
|
|
e7677df541 | ||
|
|
dda4dacb1c | ||
|
|
345156c605 | ||
|
|
cc4dd1e87b | ||
|
|
3c75c66d4d | ||
|
|
93d6fdb17e | ||
|
|
75b873d231 | ||
|
|
a912cbc52b | ||
|
|
5a99b84c25 | ||
|
|
7fda120d5a | ||
|
|
3b8233e6ba | ||
|
|
675ce53c06 | ||
|
|
d51c1ff131 | ||
|
|
8b423b97e8 | ||
|
|
1a6d129fe3 | ||
|
|
a0bf715ddf | ||
|
|
110f887181 | ||
|
|
d2c5a027d4 | ||
|
|
077665e27b | ||
|
|
5c41d2b8f4 | ||
|
|
7d9e592f84 | ||
|
|
11199e6848 | ||
|
|
19c38d4b94 | ||
|
|
851cf43180 | ||
|
|
e004df38c7 | ||
|
|
3bb504bb1f | ||
|
|
26263f8a17 | ||
|
|
fc22f53b92 | ||
|
|
654bcc4ecb | ||
|
|
2175f2fe59 | ||
|
|
81ca098db1 | ||
|
|
25118d1ec7 | ||
|
|
3e3c17d686 | ||
|
|
964855373e | ||
|
|
3c83c0cff2 | ||
|
|
4b555abefe | ||
|
|
4742112000 | ||
|
|
7f0567aa39 | ||
|
|
8fb1dcda86 | ||
|
|
94deafbaa9 | ||
|
|
2555447c7b | ||
|
|
6e91b2d142 | ||
|
|
e64eb5db95 | ||
|
|
e175115de1 | ||
|
|
9dc4f95f60 | ||
|
|
54145b65ec | ||
|
|
15f53c0a6b | ||
|
|
2fad9c95db | ||
|
|
6bffbba817 | ||
|
|
ab11f5d583 | ||
|
|
c1d2210c95 | ||
|
|
b12ae13047 | ||
|
|
8a61c038ff | ||
|
|
b5f3603be5 | ||
|
|
802713aafe |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 50.0%"><title>coverage: 50.0%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">50.0%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">50.0%</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><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -141,3 +141,41 @@ jobs:
|
||||
|
||||
- name: Generated Drift
|
||||
run: ./scripts/policy/check-generated-drift.sh
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [test, policy, edition-tests]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
# Trigger internal GitLab CI pipeline via webhook.
|
||||
# WUKONG_TRIGGER_TOKEN is a repository secret.
|
||||
if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=${{ github.sha }}" \
|
||||
"${{ secrets.WUKONG_TRIGGER_URL }}"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Issue 变更推送到 Webhook
|
||||
# 当有 Issue 变更时,发送指定格式的数据到 webhook
|
||||
name: 📤 Issue Webhook Notification
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, closed, edited, labeled, unlabeled]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 📬 Send Issue to Webhook
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhook = process.env.ISSUE_WEBHOOK_URL;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ ISSUE_WEBHOOK_URL not set, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = context.payload;
|
||||
const issue = payload.issue;
|
||||
const action = payload.action;
|
||||
|
||||
// 构建指定格式的数据
|
||||
const webhookPayload = {
|
||||
action: action,
|
||||
issue: {
|
||||
id: issue.id,
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
html_url: issue.html_url,
|
||||
labels: (issue.labels || []).map(label => label.name).join(', ') || '无标签'
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(webhookPayload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('✅ Webhook notification sent successfully');
|
||||
} else {
|
||||
console.log('❌ Failed to send webhook notification:', response.status, response.statusText);
|
||||
}
|
||||
env:
|
||||
ISSUE_WEBHOOK_URL: ${{ secrets.DINGTALK_AI_TABLE_WEBHOOK }}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Issue 自动同步到钉钉群
|
||||
# 当有新 Issue 时,自动发送到钉钉群(不包括 comment)
|
||||
name: 🔔 Issue Notification to DingTalk
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, closed, labeled]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 📬 Send Issue to DingTalk
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = context.payload;
|
||||
const issue = payload.issue;
|
||||
const action = payload.action;
|
||||
|
||||
// 构建消息标题和内容(确保包含关键字 "issue" 以支持 Custom Keywords 模式)
|
||||
const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`;
|
||||
const content = issue.body?.substring(0, 500) || 'No description';
|
||||
const url = issue.html_url;
|
||||
const labelsText = (issue.labels || []).map(label => label.name).join(', ') || '无标签';
|
||||
|
||||
// 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式
|
||||
const message = {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: 'GitHub Issue 通知',
|
||||
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n🏷️ **Labels**: ${labelsText}\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
|
||||
console.log('✅ DingTalk notification sent');
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -27,6 +28,17 @@ jobs:
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- 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:
|
||||
@@ -37,6 +49,14 @@ jobs:
|
||||
|
||||
- name: Post-release packaging
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
+7
-1
@@ -16,6 +16,7 @@ dws
|
||||
test/cli/testdata/
|
||||
tmp/
|
||||
test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
|
||||
@@ -24,4 +25,9 @@ test/cli_compat/testdata/
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
credentials*
|
||||
credentials*
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
*.code-workspace
|
||||
/dingtalk-workspace.zip
|
||||
|
||||
@@ -69,3 +69,4 @@ release:
|
||||
draft: false
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
mode: replace
|
||||
|
||||
+673
@@ -4,6 +4,679 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.32] - 2026-05-25
|
||||
|
||||
Two user-visible regressions resolved plus two AI-agent discoverability fixes. `dws drive upload` was returning `HTTP 403 SignatureDoesNotMatch` for any file whose MIME detects to a non-empty value — basically every real file — because the helper added a client-side `Content-Type` fallback whenever `drive.get_upload_info` returned an empty headers map. DingTalk drive's OSS presigned PUT URLs are signed against an empty `Content-Type` at signing time, so any client-supplied header makes the signature OSS recomputes diverge from the server-signed one, and the PUT is rejected (#347). On Apple Silicon, `dws upgrade` was aborting at the "解压并验证" step with `signal: killed` because GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ `amfid` SIGKILLs unsigned arm64 binaries on first exec (#339) — the release pipeline now ad-hoc signs every darwin tarball, and the upgrade client self-heals if it ever encounters an unsigned binary again. On the AI-agent discoverability side, `dws aitable attachment upload-file` (the one-shot prepare + PUT + commit composite) is no longer hidden from `--help` — agents that only browse the command tree were getting stuck at the prepare-only `attachment upload` step, which returns an upload URL + fileToken but doesn't actually upload. And `dws --help` itself now surfaces the missing-command upgrade hint that the custom `renderRootHelp` had been silently dropping from cobra's `root.Long`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aitable attachment upload-file` is now visible in `dws aitable attachment --help`** (#347, `internal/helpers/aitable.go`) — the hardcoded one-shot composite (prepare + HTTP PUT + commit, returns `fileToken` directly) was previously marked `Hidden:true` and only reachable by agents that read `skills/references/products/aitable.md`. Agents that only discover commands via `--help` were getting stuck at the sibling envelope-generated `attachment upload` (prepare-only): they'd receive `uploadUrl` + `fileToken`, have no idea how to consume the URL, and either write the URL into the attachment field as if it were a token (wrong shape — the field expects `[{"fileToken":"ft_xxx"}]`) or fall back to "please use the UI" messages, which made `dws` look broken even though the capability was fully implemented. Unhiding mirrors the discoverability pattern `lark-cli base +record-upload-attachment` already follows. `Short` is tightened to explicitly mention the 3 steps it bundles; `Long` calls out the prepare-only sibling and recommends `upload-file` as the default for AI agents. The sibling `attachment upload` (prepare-only) keeps its envelope-generated registration but gets a new `Long` that states it is only step 1 of a 3-step flow, lists what an agent must do after (HTTP PUT to `uploadUrl`, then write `[{"fileToken":"ft_xxx"}]` into the attachment field), and points to `upload-file` as the recommended one-shot alternative. `TestAITableUploadFileCommandIsDiscoverable` in `internal/helpers/aitable_upload_file_test.go` guards against re-introducing `Hidden:true`.
|
||||
- **`dws --help` root output now surfaces the `dws upgrade` hint when no listed command fits** (#347, `internal/app/root.go` + `internal/app/root_help.go`) — `root.Long` is set to `"提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务, 请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI 持续迭代, 新能力和 bugfix 会先在新版本上线."`. The custom `renderRootHelp` (which replaces cobra's default template to render the services / utilities sections) had been silently dropping `root.Long`; restoring it costs one `Fprintln` after the command list, separated by a blank line. The natural failure mode for both agents and users staring at `dws --help` is to give up or hack around when none of the listed commands fit — but in many cases the right action is simply `dws upgrade`, because new capabilities and bugfixes ship continuously and a missing command is usually a stale-binary issue. `TestRenderRootHelpIncludesLong` in `internal/app/visibility_test.go` uses a sentinel `Long` string and asserts the rendered output contains it verbatim, so any future rewrite of the help renderer that drops `Long` fails this test immediately.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws drive upload` no longer fails with `HTTP 403 SignatureDoesNotMatch` on any non-empty MIME type** (#347, `internal/helpers/drive.go`) — `httpPutDriveFile` was setting `req.Header["Content-Type"] = fallbackMIME` whenever the prepare_upload response returned an empty headers map. DingTalk drive's OSS presigned URLs sign `StringToSign` against an empty `Content-Type` at signing time, so any client-side header makes the signature OSS recomputes at PUT time differ from the server's presignature, and the upload is rejected with `403 SignatureDoesNotMatch`. This broke every `dws drive upload` for any file whose MIME detects to a non-empty value (`image/png`, `application/pdf`, every common binary) — i.e. essentially every real upload. Fix: drop the `hasContentType` / `fallbackMIME` path entirely, trust the server's headers map as authoritative; empty map means "no client-side headers needed", do not infer. `httpPutDriveFile`'s signature loses the `fallbackMIME` parameter. Manual verification: `curl -X PUT -H "Content-Type:" --data-binary @file <same-presigned-url>` returns `HTTP 200`, proving the only difference was the client-side `Content-Type`. `TestHttpPutDriveFile_NoContentTypeWhenServerHeadersEmpty` guards the empty-map path; `TestHttpPutDriveFile_PassthroughServerHeaders` guards that server-provided `Content-Type` / `x-oss-*` headers are forwarded verbatim. Important: `internal/helpers/aitable.go`'s `upload-file` helper deliberately keeps its `Set("Content-Type", mimeType)` call — its OSS endpoint uses a different signing mode (server includes the client-declared MIME in the signature, verified across 12 file types — all succeed). The two helpers must not be unified without re-validating both endpoints.
|
||||
- **`dws upgrade` no longer dies with `signal: killed` on Apple Silicon after fetching the new binary** (#339) — GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ on Apple Silicon requires at least an ad-hoc signature on every arm64 binary; `amfid` SIGKILLs unsigned arm64 binaries on first exec, which the upgrade client surfaces as `signal: killed` and aborts at the "解压并验证" step. Two layers of fix:
|
||||
- **Release-side ad-hoc signing** (`scripts/release/post-goreleaser.sh` + `.github/workflows/release.yml`) — after GoReleaser produces the per-platform tarballs, `post-goreleaser.sh` unpacks each `dws-darwin-*.tar.gz`, applies an ad-hoc signature (`codesign --force --sign -` locally, `rcodesign` in CI), deterministically repacks the tarball, and rewrites the matching line in `checksums.txt` so the checksum stays consistent with the resigned tarball. `release.yml` installs `rcodesign 0.27.0` before GoReleaser runs. Every 1.0.32+ tarball ships signed; the install regression is fixed at the source.
|
||||
- **Client-side self-heal in `validateNewBinary`** (`internal/app/upgrade.go`) — when running the freshly-extracted binary returns `signal: killed` on darwin, the validator retries once after running `codesign --force --sign -` on the binary and clearing the `com.apple.quarantine` xattr. This keeps `dws upgrade` working even if a future release ever skips the signing step again, and covers users upgrading from older unsigned binaries. `internal/app/upgrade_test.go` (+80 lines) covers the retry path end-to-end: a stripped binary exits 137 on first exec, `validateNewBinary` recovers via ad-hoc sign + xattr clear, the final binary shows `Signature=adhoc` and runs.
|
||||
|
||||
## [1.0.31] - 2026-05-21
|
||||
|
||||
Closes the last drive-surface gap with the Wukong edition: `dws drive upload` lands as a single-shot composite (`drive.get_upload_info` → HTTP PUT to OSS → `drive.commit_upload`) so a local file reaches DingTalk drive in one CLI invocation, no manual three-step orchestration. Two more drive commands — `dws drive list-spaces` (list visible drive spaces) and `dws drive delete` (delete a drive file, routed via `serverOverride` to the doc MCP server) — ship via the portal envelope; `dws cache refresh` once to pick them up. Companion skill docs teach the agent to recognise dingpan URLs of the form `alidocs.dingtalk.com/document/edit?dentryKey=…` / `…/document/preview?dentryKey=…` and pass the whole URL through to `--node` instead of trying to extract `dentryKey` by hand (the server interprets `dentryKey` and a bare `nodeId` differently — manual extraction was failing).
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws drive upload --file <path> [--folder <dentryUuid>] [--space-id <id>] [--file-name <name>] [--mime-type <type>]`** (#335, see `internal/helpers/drive.go`) — composite leaf that runs the full three-step upload internally:
|
||||
1. `drive.get_upload_info` — fetch the OSS-signed `resourceUrl` + `uploadId` + per-URL headers.
|
||||
2. HTTP `PUT` the file binary to OSS (10-minute timeout, attaches every header returned by step 1).
|
||||
3. `drive.commit_upload` — register the new file under the target space / folder.
|
||||
|
||||
`--dry-run` prints the three step invocations as a single JSON payload without making any network calls. `--file -` is rejected on purpose: this is a local-path upload, not stdin streaming. `--folder` only accepts a `dentryUuid`; pure-numeric values are rejected up front (`validateDriveParentID`) so callers don't accidentally pass a chat-link `dentryId` (a different ID namespace) where the drive API expects a `dentryUuid`. Response normalisation handles all the wrapper shapes the upstream returns — `content` / `result` envelopes, `resourceUrls[]` arrays, and the flat `resourceUrl` / `uploadUrl` fallbacks — so the composite produces a stable JSON shape regardless of which path the upstream takes. The helper only registers `upload`; the existing six envelope-generated leaves (`list` / `info` / `download` / `mkdir` / `upload-info` / `commit`) keep flowing through dynamic discovery unchanged. `pickCommands.MergeHardcodedLeaves` guarantees dynamic leaves win on collision, so this helper only fills the upload gap.
|
||||
- **`dws drive list-spaces` and `dws drive delete` (envelope rollout)** (#335, ships via portal envelope) — `list_spaces` registers as a plain `cliName` alias on the existing drive MCP server; `delete_document` registers with `serverOverride: doc` so the call routes to the doc MCP server (which owns the delete API), surfacing under the drive command tree for ergonomics. **Existing users must run `dws cache refresh` once** to pick up these two new leaves; no binary upgrade is required for them, but they pair naturally with the v1.0.31 client that ships `upload`.
|
||||
- **`skills/references/url-patterns.md`** (#335) — single authority for dispatching `alidocs.dingtalk.com` URLs across doc / sheet / wiki. Five-way split: `/i/p/<token>` short links → expand via `doc info`; `/i/nodes/<id>` node URLs → probe with `doc info` and route by `contentType` / `extension` / `nodeType`; `/spreadsheetv2/...` → `sheet`; `/document/edit|preview?dentryKey=<key>` (dingpan format) → pass the whole URL to `--node`, do not strip `dentryKey` by hand; `/i/share/...` (read-only share) → use the `read_url` fallback. The "URL precheck" Step 0 in `skills/SKILL.md` now redirects every URL-bearing prompt through this dispatcher before the agent picks a product.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`skills/references/products/doc.md` — `--node` accepts dingpan URLs end-to-end** (#335) — `dws doc info` / `dws doc read` examples gain two extra rows showing `--node "https://alidocs.dingtalk.com/document/edit?dentryKey=<KEY>"` and `…/preview?dentryKey=<KEY>` as first-class `--node` inputs. The "URL recognition & DOC_ID extraction" table adds the `document/edit|preview?dentryKey=<key>` row, and the extraction rules are split into three explicit clauses so the agent stops manually pulling `dentryKey` out of the URL and feeding it as a bare `nodeId` (which the server rejects). The "nodeId dual-format note" upgrades to "nodeId multi-format note" with four equivalent `--node` input shapes side by side.
|
||||
|
||||
## [1.0.30] - 2026-05-19
|
||||
|
||||
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
|
||||
|
||||
### Added
|
||||
|
||||
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
|
||||
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
|
||||
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
|
||||
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
|
||||
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
|
||||
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
|
||||
|
||||
## [1.0.29] - 2026-05-17
|
||||
|
||||
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
|
||||
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
|
||||
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
|
||||
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
|
||||
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
|
||||
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
|
||||
|
||||
### Security
|
||||
|
||||
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
|
||||
|
||||
## [1.0.28] - 2026-05-14
|
||||
|
||||
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
|
||||
|
||||
## [1.0.27] - 2026-05-14
|
||||
|
||||
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
|
||||
|
||||
### Added
|
||||
|
||||
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
|
||||
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
|
||||
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
|
||||
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
|
||||
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
|
||||
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
|
||||
|
||||
## [1.0.26] - 2026-05-12
|
||||
|
||||
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
|
||||
|
||||
### Added
|
||||
|
||||
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
|
||||
|
||||
### Added
|
||||
|
||||
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
|
||||
- **PAT auth URLs were truncated on Windows browser open** (#242, fixes #230) — `cmd /c start <url>` on Windows interprets `&` as a command separator, so PAT URLs containing `&userCode=...` were silently chopped before the userCode segment, and the browser landed on a 0-permission DingTalk page. The retry opener now uses `rundll32 url.dll,FileProtocolHandler`, which passes the URL through verbatim. The PAT response also exposes a copy-safe `data.authorizationUrl` (in addition to the service-provided `data.uri`, which is preserved as-is), and human-readable PAT output prints `PAT_AUTHORIZATION_URL=<full-url>` on its own line so OpenClaw-style host wrappers that swallow or reformat stderr can still capture the full link. Legacy DingTalk hash-route shapes (`https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId=...%26userCode=...`) are normalised back into the working `/fe/old?hash=...#/personalAuthorization?...&userCode=...` form. Regression tests cover the issue-shaped URLs (encoded hash, fragment, `&userCode`) plus the OpenClaw malformed-hash variant.
|
||||
- **`dws doc download` triggered `drive:download` PAT consent for unsupported axls nodes** (#268, fixes #190) — added a `get_document_info` preflight before `download_file`, so online-sheet (`axls`) nodes are rejected locally with guidance to use sheet range tools instead. The preflight reads `extension` from deterministic response paths (no recursive payload scan) and routes its own PAT errors back through `handlePatAuthCheck`, preserving device-flow / host-owned PAT behaviour. Costs one extra MCP roundtrip per `doc download` — deliberate, so the unsupported path fails before consent. Lives in `internal/app/doc_download_preflight.go`; coverage in `internal/app/runner_test.go`.
|
||||
- **macOS sandbox hosts (Codex App etc.) couldn't read/write tokens via Keychain** (#267, fixes #214) — sandboxed macOS environments intercept `security` / Keychain APIs, so every token operation failed. New opt-in `DWS_DISABLE_KEYCHAIN=1` switches macOS to the same file-DEK path Linux uses (DEK at `~/Library/Application Support/dws-cli/dek`, mode `0600`), bypassing the system Keychain. Default behaviour is unchanged — fallback is strictly opt-in because file-DEK is a weaker trust model than Keychain-managed storage (DEK file sits next to ciphertext in the same directory). The Darwin / Linux file-DEK implementation is now shared in `internal/keychain/file_dek.go` (Linux path deduplicated by ~40 lines). Documented in `docs/reference.md` (中英) with the security tradeoff spelt out so users make the choice explicitly.
|
||||
- **`dws doc comment {list,create,create-inline,reply}` returned `PARAM_ERROR - 未找到指定工具`** (fixes #240, also #234) — the four comment tools used to live on an independent `doc-comment` MCP server. After the Portal merged comment functionality into the `doc` server descriptor, the runtime `tools/list` on the merged `doc` server didn't include them, so every `dws doc comment *` call returned the "tool not found" PARAM_ERROR. The market metadata for the `doc` server now declares `serverOverride: "doc-comment"` on all four comment `toolOverrides`, so the existing CLI routing path sends `dws doc comment *` to the still-running `doc-comment` MCP server (which has the tools). No CLI code change was required, but **existing users must run `dws cache refresh` once** to pick up the updated descriptor — without that, the stale local market cache keeps pointing the call at the merged `doc` server and the error persists. Verified post-refresh: dry-run resolves to `https://mcp-gw.dingtalk.com/server/doc-comment` with tool `list_comments`, real calls return normal business responses (e.g. legitimate cross-org authz errors) instead of `未找到指定工具`.
|
||||
|
||||
## [1.0.25] - 2026-05-11
|
||||
|
||||
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands. Plus missing skill references for the already-registered `sheet` and `wiki` products are now shipped.
|
||||
|
||||
### Added
|
||||
|
||||
- **`sheet` (在线电子表格) skill reference + product-overview entry** — the `sheet` product registers **34 envelope tools** covering worksheet CRUD (`create` / `new` / `list` / `info` / `copy_sheet` / `update_sheet`), range read/write (`range read` / `range update` / `append`), dimension ops (`add-dimension` / `insert-dimension` / `delete-dimension` / `move-dimension` / `update-dimension`), merge (`merge-cells` / `unmerge-cells`), find/replace (`find` / `replace`), filter views (`filter-view {create, list, update, delete, update-criteria, delete-criteria}`), sheet-level filters (`create_filter` / `get_filter` / `update_filter` / `delete_filter` / `set_filter_criteria` / `clear_filter_criteria` / `sort_filter`), image write (`write-image`), and async export (`submit_export_job` + `query_export_job`). These were live in the envelope but `skills/references/products/sheet.md` had not shipped and `skills/SKILL.md` 产品总览 didn't list `sheet`, so agents had no reference to consult and were skipping it during intent routing. This release adds the doc, registers `sheet` in 产品总览 + 意图判断决策树, extends `description` to include 在线电子表格, adds a Sheet row to `README.md` / `README_zh.md` "Key Services", and notes the v1.0.25 reality on naming (about a third of `sheet` tools still expose snake_case cli_names pending `CLIAliases` (#246) rollout) and on export (no consolidated `dws sheet export` exists in v1.0.25 — `submit_export_job` + `query_export_job` are the atomic primitives; Pipeline (#247) provides the future plumbing).
|
||||
- **`wiki` (知识库) skill reference + product-overview entry** — the wiki product's 7 envelope tools (`wiki.create_wikiSpace`, `wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, `wiki.add_member`, `wiki.list_member`, `wiki.update_member`, surfaced as `dws wiki space create / get / list / search` and `dws wiki member add / list / update`) have been registered for a while, but no `skills/references/products/wiki.md` shipped with them, so agents had no per-command reference to consult. This release adds the reference doc, registers `wiki` in `skills/SKILL.md`'s 产品总览 table and 意图判断决策树, mentions 知识库 in the skill `description` frontmatter, adds a Wiki row to `README.md` / `README_zh.md` "Key Services", and removes `wiki` from the "Coming soon" callout (which was now stale).
|
||||
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
|
||||
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
|
||||
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
|
||||
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
|
||||
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
|
||||
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
|
||||
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
|
||||
|
||||
## [1.0.24] - 2026-05-09
|
||||
|
||||
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
|
||||
|
||||
### CI
|
||||
|
||||
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
|
||||
|
||||
## [1.0.23] - 2026-05-08
|
||||
|
||||
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
|
||||
|
||||
## [1.0.22] - 2026-05-07
|
||||
|
||||
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
|
||||
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
|
||||
|
||||
### Tests
|
||||
|
||||
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
|
||||
|
||||
## [1.0.21] - 2026-05-05
|
||||
|
||||
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
|
||||
|
||||
## [1.0.20] - 2026-05-04
|
||||
|
||||
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
|
||||
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
|
||||
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
|
||||
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
|
||||
- **CHANGELOG backfill for 1.0.19** (#204).
|
||||
|
||||
## [1.0.19] - 2026-04-30
|
||||
|
||||
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`pkg/config.EditionPartition(name)`** (#197) — returns the cache partition key for a given edition. Open-source core (`""` / `"open"`) keeps using `DefaultPartition` (`default/default`); every other edition gets its own namespace (`<edition>/default`), preventing cross-edition data leakage in the shared `~/.dws` disk cache. Lives in `pkg/config` as a leaf helper so `internal/cli`, `internal/app`, and `internal/cache` can all call it without risking import cycles.
|
||||
- **`internal/editionmerge` shared package** (#197) — single source of truth for converting `edition.ServerInfo` into `market.ServerDescriptor` (`ToDescriptor`) and for merging `SupplementServers` / `FallbackServers` into a descriptor list. Both `internal/cli` (command tree) and `internal/app` (runtime catalog) now apply the edition hooks against the same discovery pipeline.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`EnvironmentLoader.loadFromCache` honors `SupplementServers` even on empty registry** (#197) — when the Portal registry cache is missing or empty, the catalog loader still materialises the edition's `SupplementServers` as endpoint-only `discovery.RuntimeServer` entries (source: `edition_supplement`), so hardcoded overlay commands for supplement-only products can still resolve an endpoint via the catalog path. Previously `loadFromCache` short-circuited to an empty catalog whenever the registry snapshot was empty, silently dropping gray-release products.
|
||||
- **Cache loader switches from `DefaultPartition` to `EditionPartition(edition.Get().Name)`** (#197) — the runtime catalog, registry snapshot, and tools snapshot are now partitioned per edition instead of all editions sharing `default/default`.
|
||||
- **`loadFromCache` appends supplement servers alongside fresh-cache servers** (#197) — supplement entries whose `CLI.ID` / `Key` are already present in the cached registry are skipped, so the hook never shadows Portal-published servers; only new products are added.
|
||||
- **`runtimeRunner.Run` falls through to `directRuntimeEndpoint` for supplement products** (#197) — when the catalog contains the product (e.g. supplied by `SupplementServers`) but the specific tool is not declared, the runner now trusts `directRuntimeEndpoint` to resolve a working endpoint for the tool before returning the explicit catalog-miss error. Supplement entries intentionally carry no tool list, so this is the path that makes overlay-only tools executable.
|
||||
- **Legacy `mergeSupplementServers` / `fallbackToDescriptors` moved out of `internal/app/legacy.go`** (#197) — relocated into `internal/editionmerge` and reused by the catalog loader, eliminating the duplicate `edition.ServerInfo → market.ServerDescriptor` logic that previously only ran on the static command-tree path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws todo task get` returns empty** (#202) — the helper was calling `query_todo_detail`, which is not a valid MCP tool and returns empty. Switched to `get_todo_detail` as declared in `discovery.json`, restoring correct task-detail behaviour.
|
||||
- **Conference and other Wukong gray-release products miss endpoint on cold cache** (#197) — products registered only via `edition.SupplementServers` (not yet in the Portal envelope) now resolve an endpoint through the catalog path in both cold-start and tool-not-declared scenarios.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/editionmerge/merge_test.go` — descriptor conversion + supplement/fallback merge semantics.
|
||||
- `internal/cli/loader_partition_test.go` + `loader_supplement_test.go` — edition-partitioned cache reads and supplement hook surfacing from `loadFromCache` (including empty-registry cold path and existing-ID deduplication).
|
||||
- `internal/app/legacy_wukong_partition_e2e_test.go` — end-to-end cache partition isolation for the Wukong edition.
|
||||
- `internal/app/runner_supplement_fallback_test.go` — runner falls through to `directRuntimeEndpoint` when the tool isn't declared by a supplement-sourced catalog entry.
|
||||
- `pkg/config/constants_test.go` — `EditionPartition` name handling (`""`, `"open"`, custom edition).
|
||||
|
||||
### Docs
|
||||
|
||||
- **CHANGELOG v1.0.18 rewrite** (#193) — previous release notes expanded to call out the PAT host-owned A-core flow, exit-code contract change (auth `4`, Discovery/cache/protocol `6`), `dws pat chmod` / `pat browser-policy` entry points, stderr-JSON classifier updates, and host-control metadata injection.
|
||||
|
||||
## [1.0.18] - 2026-04-28
|
||||
|
||||
Raw DingTalk OpenAPI access lands as a new `dws api` surface for both `api.dingtalk.com` and `oapi.dingtalk.com`, backed by app-level token caching and guarded host allowlists. PAT enters the host-owned **A-core** loop: agent hosts can own authorization UI through `DINGTALK_DWS_AGENTCODE`, parse single-line stderr JSON, call `dws pat chmod`, and replay the original command. Chat helper regressions are fixed, skill references are brought back in line with shipped commands, and the v1.0.17 Mail release notes are backfilled into README / CHANGELOG.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **PAT exit-code contract** (#142) — PAT authorization interceptions now use exit code `4`; Discovery, cache, and protocol negotiation failures now use exit code `6`. Downstream scripts that previously treated `4` as Discovery must update their handling.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws api` raw DingTalk OpenAPI command** (#184) — direct DingTalk OpenAPI calls without writing an MCP wrapper first. Supports `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, JSON `--params` / `--data`, stdin input, dry-run previews, `--jq`, field selection, `--page-all`, `--page-limit`, `--page-delay`, and `--base-url`.
|
||||
- **Dual-form OpenAPI routing** (#184) — `api.dingtalk.com` requests use the `x-acs-dingtalk-access-token` header; `oapi.dingtalk.com` requests use the legacy `access_token` query parameter. The raw API client validates the target host before attaching credentials.
|
||||
- **App-level token cache for raw API** (#184) — custom-app credentials now fetch app access tokens from the unified OAuth endpoint, cache them while valid, and refresh them before expiry. The same token provider works for new-style and legacy OpenAPI calls.
|
||||
- **Host-owned PAT A-core flow** (#142) — when `DINGTALK_DWS_AGENTCODE` is set, PAT hits return `exit=4` plus single-line stderr JSON; the host renders authorization UI, calls `dws pat chmod <scope>...`, and replays the original command.
|
||||
- **`dws pat chmod` authorization entry point** (#142) — grants scopes with `--agentCode`, `--grant-type`, and session fallback support; `DINGTALK_DWS_AGENTCODE` can supply the agent code when the flag is omitted.
|
||||
- **PAT browser-open policy** (#142) — `dws pat browser-policy --enabled <true|false> [--agentCode <id>]` controls whether the CLI may open a browser, independently from `--format` output mode.
|
||||
|
||||
### Changed
|
||||
|
||||
- **README raw API guide** (#184) — English and Chinese READMEs now document custom-app prerequisites, api/oapi examples, auto-pagination, dry-run, jq filtering, security properties, and the new Raw API service-table row.
|
||||
- **Raw API token retrieval path** (#184) — token lookup now goes through a single app-token interface; stale auth-refresh retry helpers were removed from the raw API path.
|
||||
- **PAT stderr JSON classifier** (#142) — recognizes `code`, `errorCode`, and `error_code`, including `PAT_NO_PERMISSION`, risk-tier PAT errors, `PAT_SCOPE_AUTH_REQUIRED`, and `AGENT_CODE_NOT_EXISTS`.
|
||||
- **Host-control metadata injection** (#142) — classifier and active-retry paths now share one mutation point for `data.hostControl` and `data.openBrowser`, keeping host-facing JSON shapes aligned.
|
||||
- **Open-edition routing signals** (#142) — open edition pins `claw-type: openClaw`; `DINGTALK_AGENT`, `DWS_CHANNEL`, and host-owned PAT detection are kept as independent signals.
|
||||
- **Behavior authorization endpoint fallback** (#142) — the PAT runtime can resolve the built-in behavior-authorization MCP endpoint before discovery data is available.
|
||||
- **v1.0.17 documentation backfill** (#181) — the previous release notes and README service table now explicitly include the shipped Mail product, update the total to **163 commands across 14 products**, and remove Mail from "Coming soon".
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CLI auth-denial attribution** — local CLI authorization denials are attributed to the channel before falling back to user-scope classification, avoiding user-scope misclassification for channel-level auth failures.
|
||||
- **Opaque authorization URLs** (#182, #142) — PAT authorization links are preserved verbatim, including query/hash/fragment content required by the server.
|
||||
- **Polling compatibility** (#182, #142) — device-flow result envelopes and no-`flowId` device-code fallback remain supported, with guarded debug output and envelope priority.
|
||||
- **Group chat @-mentions restored** (#180) — `dws chat message send --group ...` again accepts and forwards `--at-users`, `--at-all`, and `--at-mobiles`; those flags are rejected outside group-chat mode so single-chat sends cannot silently drop @-mention intent.
|
||||
- **Explicit members-list command restored** (#180) — `dws chat group members list --id <openConversationId>` is reachable after the helper/dynamic merge path changed. `cmdutil.MergeHardcodedLeaves` now honors higher-priority helper groups when a dynamic envelope contributes a leaf at the same path.
|
||||
- **Skill reference command names** (#186) — `simple.md` now uses shipped OA command names (`list-pending`, `list-initiated`), removes a non-existent devdoc `search-error` command, and marks `workbench.md` as Draft because workbench commands are not available in the runtime.
|
||||
- **Empty grant result handling** (#142) — `dws pat chmod` now returns an explicit error instead of treating `{"Content": null}` as success.
|
||||
- **Session-id log safety** (#142) — raw `DWS_SESSION_ID` / `REWIND_SESSION_ID` values are no longer logged when the two env vars disagree.
|
||||
|
||||
### Tests
|
||||
|
||||
- Added raw API coverage for request validation, api/oapi routing, token management, pagination, response handling, dry-run output, JSON parsing, stdin handling, and command wiring. (#184)
|
||||
- Added chat/cmdutil regression tests for group @-mention forwarding, single-chat rejection, `members list`, helper-vs-envelope shape mismatch, and merge-priority behavior. (#180)
|
||||
- Added PAT contract coverage for host-owned signal selection, single-line stderr JSON, chmod env fallback and legacy alias fallback, browser policy, direct-runtime PAT endpoint fallback, and retry/poll behavior. (#142)
|
||||
- Coverage badge refreshed after the post-v1.0.17 CI runs.
|
||||
|
||||
## [1.0.17] - 2026-04-27
|
||||
|
||||
New **Mail** product surface (mailbox list, KQL message search, message get, send) brings runtime command count to **163 across 14 products**. Plugin command-tree visibility hardening: stdio plugins shipping CLI overlays no longer wait on subprocess discovery to surface their commands, and overlay-registered plugin products are no longer hidden by edition `VisibleProducts` whitelists. Chat docs clarify that `--title` is required on `dws chat message send`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`mail` product** (#167) — new top-level service for DingTalk Mail. Four leaf commands across two subgroups:
|
||||
- `dws mail mailbox list` — list mailbox addresses available to the current user (`list_user_mailboxes`)
|
||||
- `dws mail message search` — KQL search across folders / sender / date / attachments / read-state (`search_emails`); supports `--cursor` pagination
|
||||
- `dws mail message get` — fetch full message body + headers + attachments by message ID (`get_email_by_message_id`)
|
||||
- `dws mail message send` — send email to one or more recipients (`send_email`)
|
||||
- Skill reference at `skills/references/products/mail.md` registered in `skills/SKILL.md` master index and intent decision tree
|
||||
- **Stdio plugin overlay-first command registration** (#179) — when a stdio plugin's `overlay.json` declares `toolOverrides`, command trees are built from manifest metadata synchronously at startup, no subprocess `Initialize` / `tools/list` handshake required. Previously, slow or failing subprocesses left plugin commands invisible in `dws --help`. Background discovery still runs to refresh the warm cache for richer flag types on subsequent startups.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`hideNonDirectRuntimeCommands` / `visibleMCPRootCommands` / `visibleUtilityRootCommands`** (#179) — refactored to share a single `resolveVisibleProducts()` helper that **unions** the edition's `VisibleProducts` hook with `DirectRuntimeProductIDs()`, so plugins registered via `AppendDynamicServer` stay visible in `dws --help` even when an edition installs a static product whitelist. Previously the hook fully replaced the dynamic registry, silently hiding plugin commands.
|
||||
- **`dws chat message send` documentation clarifies `--title` is required** (#174) — the helper command short text and the chat skill reference now state explicitly that `--title` is mandatory for both group and single-chat sends, matching the runtime validation.
|
||||
- **`buildStdioCommands` refactored to share helpers with the overlay-first path** (#179) — overlay parsing (`resolveStdioOverlay`) and tools→DetailTool conversion (`toolsToDetails`) extracted as package-level helpers; the legacy discovery-first stdio path now delegates to them, eliminating duplicated overlay JSON / cache-snapshot logic.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Negative-cache poisoning guard for stdio plugin discovery** (#179) — `refreshStdioToolsCache` now skips `SaveTools` entirely when discovery returns an empty tool list (transient failure, subprocess not ready, RPC timeout), so a single bad refresh cannot overwrite a previously-good cache and degrade flag enrichment on the next startup.
|
||||
|
||||
### Tests
|
||||
|
||||
- 6 new test cases in `internal/app/plugin_stdio_overlay_test.go` and `internal/app/visibility_test.go` cover overlay-first registration without discovery, warm-cache flag enrichment from `InputSchema`, fallback when overlays lack `toolOverrides`, the cache-poisoning guard, and integration cases for plugin visibility under restrictive `VisibleProducts` whitelists.
|
||||
- Coverage 49.8% → 52.8%.
|
||||
|
||||
## [1.0.16] - 2026-04-24
|
||||
|
||||
Discovery service abstraction with schema v3 extensions, open-edition helper-subtree restoration, and a defensive device-flow login reset.
|
||||
|
||||
### Added
|
||||
|
||||
- **`internal/discovery` service abstraction** (#156) — encapsulates market registry fetch, MCP runtime negotiation (`initialize → tools/list → detail` merge), and multi-level cache fallback. `EnvironmentLoader` now does cache-first startup, with degraded-mode reasons (`unauthenticated` / `market_unreachable` / `runtime_all_failed`) and `UpdatedAt`-based selective re-discovery.
|
||||
- **Schema v3 extensions** (#156) — positional parameters with typed coercion, `Example` on `--help`, flag `Default` / `RuntimeDefault` (with `$currentUserId` / `$now` etc.), `BodyWrapper`, `MutuallyExclusive` / `RequireOneOf` flag groups, `OmitWhen`, explicit `Type` override, and detail-schema `default` propagation.
|
||||
- **`dws chat message send` destination-flag routing** (#170) — open edition gains a hardcoded helper that dispatches by `--group` (→ `send_message_as_user`) vs `--user` / `--open-dingtalk-id` (→ `send_direct_message_as_user`), mirroring the closed-source overlay so single-chat sends finally work end-to-end.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`pickCommands` → `cmdutil.MergeHardcodedLeaves`** (#169) — when a top-level product name collides between the dynamic overlay and a helper subtree, helper-only siblings are grafted into the dynamic tree instead of dropped. Restores `dws chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` and `dws chat group members add-bot`, which had silently vanished from the open edition.
|
||||
- **`OverridePriority` / `MergeHardcodedLeaves` promoted into `pkg/cmdutil`** (#170) — single source of truth for the merge layer; hardcoded leaves can opt into overriding the dynamic envelope via a strictly higher priority.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Device flow defensively resets credentials before login** (#157) — `--device` login now clears stale credential state and re-fetches `clientID` from the MCP server, regardless of what previous login methods (OAuth scan, PAT) left in `app.json`. Fixes the case where a prior OAuth login made `--device` fall back to direct mode and demand `clientSecret`.
|
||||
|
||||
## [1.0.15] - 2026-04-23
|
||||
|
||||
Compat layer gains **subcommand merging** under shared parents so multiple server entries can contribute into the same `dws <parent> <branch>` subtree without producing duplicate `--help` rows. Ships with a fresh auto-generated command index doc, a README sync to **159 commands across 13 products**, and a wide-ranging flag-naming cleanup that standardises CLI flags across chat, calendar, drive, minutes, contact, and devdoc commands.
|
||||
|
||||
### Added
|
||||
|
||||
- **`internal/compat` subcommand merging via `attachOrMerge`** — when two or more server entries attach to the same parent (e.g. `parent: "chat"`) and their `cli.command` collides with an existing subcommand in the parent's tree, the new subcommand's children are merged recursively into the existing one instead of creating a duplicate sibling. Leaf-name collisions resolve first-wins. Fixes the "double `group` / `message` rows in `dws chat --help`" symptom when bot capabilities are distributed across `chat.group.members` and `chat.message`.
|
||||
- **`docs/command-index.md`** — a single, English, auto-generated listing of every runtime command the `dws` CLI exposes under the pre environment (159 total). Each entry carries a description and a "when to use" column aimed at AI agents. Replaces the earlier `command-index.pre.*` / `command-index.full.*` ad-hoc snapshots.
|
||||
|
||||
### Changed
|
||||
|
||||
- **README Key Services table** (`README.md` + `README_zh.md`) fully synced to the shipped command surface:
|
||||
- `Chat`: 20 → **23** (bot capabilities merged in; new `list-all` / `list-focused` / `list-unread-conversations` / `conversation-info` exposed)
|
||||
- `Calendar`: 13 → **14**
|
||||
- `AI Tables`: 37 → **41** (chart / dashboard public-share config rows)
|
||||
- `Doc`: 16 → **21** (comment subtree + `file create`)
|
||||
- `Minutes`: 22 → **19** (single-tool `record`, `list query`, `list-by-keyword-range` pruned)
|
||||
- New `Drive` row (6 commands) — promoted out of "Coming soon"
|
||||
- `Workbench` row and standalone `Bot` row removed
|
||||
- Total revised to **159 commands across 13 products**
|
||||
- **Quick Start** expanded to 7 examples covering `doc`, `minutes`, `drive` in addition to `contact`, `calendar`, `todo`
|
||||
- **Coming soon** trimmed to 5: `mail`, `conference`, `aiapp`, `live`, `wiki`
|
||||
- **Reference & Docs** section now leads with a pointer to the new `docs/command-index.md`
|
||||
- **Flag naming cleanup** — CLI flags across chat, calendar, drive, minutes, contact, and devdoc have been standardised so the names users type match the product-skill documentation. Notable flags:
|
||||
- `dws contact user search` / `dws contact dept search` / `dws devdoc article search` now take `--query` (previously `--keyword`)
|
||||
- `dws chat message list` / `dws chat message search` / `dws chat message list-mentions` / `dws chat conversation-info` / `dws chat message send` now take `--group` for the target conversation (previously `--id`) and `--open-dingtalk-id` (previously `--open-id`)
|
||||
- `dws chat message list-by-sender` now takes `--sender-user-id` / `--sender-open-dingtalk-id` (previously `--user` / `--open-id`)
|
||||
- `dws chat message list-topic-replies` now takes `--group` / `--topic-id` / `--limit` / `--time` (previously `--id` / `--topic` / `--size` / `--start`)
|
||||
- `dws chat search-common` now takes `--match-mode` (previously `--mode`)
|
||||
- `dws drive list` now takes `--max` / `--thumbnail` (previously `--max-results` / `--with-thumbnail`)
|
||||
- `dws calendar event suggest` now takes `--users` / `--duration` / `--timezone` (previously `--attendee-user-ids` / `--duration-minutes` / `--time-zone`)
|
||||
- `dws minutes list mine` / `dws minutes list shared` now take `--max` (previously `--max-results`) and gain `--query` / `--start` / `--end`
|
||||
- `dws minutes list all` no longer exposes the legacy `--__scope__` internal alias
|
||||
- **Flag coverage additions** — `dws calendar event create` / `update` gain `--attendees`, `--open-dingtalk-ids`, `--timezone`; `dws chat message send` gains file-message flags (`--dentry-id`, `--file-name`, `--file-size`, `--file-type`, `--media-id`, `--msg-type`, `--space-id`) plus `--open-dingtalk-id` / `--user`; `dws chat message list` gains `--open-dingtalk-id` / `--user`; `dws aitable table delete` gains `--reason`; `dws calendar participant add` gains `--optional`; `dws todo task create` gains `--recurrence`.
|
||||
|
||||
### Tests
|
||||
|
||||
- 3 new unit tests in `internal/compat/dynamic_commands_test.go`:
|
||||
- `TestBuildDynamicCommands_ParentMergeSameName` — two servers with identical `command` + `parent` collapse into a single merged subcommand
|
||||
- `TestBuildDynamicCommands_ParentMergeRecursive` — recursive merge through nested groups (e.g. `chat.group.members`)
|
||||
- `TestBuildDynamicCommands_ParentMergeLeafCollision` — identical leaf paths resolve first-wins without producing duplicates
|
||||
|
||||
## [1.0.14] - 2026-04-22
|
||||
|
||||
Docs-only re-tag of v1.0.13. The single commit (#153) backfills the v1.0.13 release notes after the binary was already published; no functional or CLI surface change.
|
||||
|
||||
## [1.0.13] - 2026-04-22
|
||||
|
||||
IM / Messaging capability expansion: the `chat` (aka `im`) product surface grows from "group + bot messaging" into a full conversational layer — user-identity messaging, message reading & search, personal messages, topic replies, mentions, focused contacts, unread/top/common conversations, org-wide group creation, and first-class bot lifecycle.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws im` alias** — `dws im` is now registered as an alias of `dws chat` for intent clarity
|
||||
- **User-identity messaging** (`chat message send`) — send group or 1-on-1 messages as the current user
|
||||
- Recipient selection is mutually exclusive: `--group <openConversationId>` / `--user <userId>` / `--open-dingtalk-id <openDingTalkId>`
|
||||
- Markdown text via `--text` (or positional arg), optional `--title`
|
||||
- Group-only: `--at-all` to @everyone, `--at-users` for per-member @mentions
|
||||
- Image messages via `--media-id` (obtained from `dt_media_upload`)
|
||||
- **Personal messages** (`chat message send-personal`) — sensitive personal-channel send (⚠️ destructive/dangerous op, requires confirmation)
|
||||
- **Conversation read paths**:
|
||||
- `chat message list` — pull group / 1-on-1 conversation messages
|
||||
- `chat message list-all` — pull all conversations for the current user in a time range
|
||||
- `chat message list-topic-replies` — pull group topic reply threads
|
||||
- `chat message list-by-sender` — messages by a specific sender
|
||||
- `chat message list-mentions` — messages where the current user was @-mentioned
|
||||
- `chat message list-focused` — messages from focused / starred contacts
|
||||
- `chat message list-unread-conversations` — unread conversation list
|
||||
- `chat message search` — keyword search across conversations
|
||||
- `chat message info` — conversation metadata
|
||||
- `chat list-top-conversations` — pinned conversation list
|
||||
- **Group creation & discovery**:
|
||||
- `chat group create-org` — create an organization-wide group
|
||||
- `chat search-common` — search groups shared with a nickname list (`--nicks`, `--match-mode AND|OR`, cursor-based pagination)
|
||||
- **Bot lifecycle**:
|
||||
- `chat bot create` — create an enterprise bot
|
||||
- `chat bot search-groups` — search the groups a bot is present in
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chat` skill reference** (`skills/references/products/chat.md`, #148) restructured into three sub-groups — `group` (9) / `message` (15) / `bot` (3) — with refreshed intent-routing table, workflow examples, and context-passing rules aligned with `dws-service-endpoints.json` (16 new group-chat tool overrides + 2 new bot tool overrides)
|
||||
- **README Key Services** sync:
|
||||
- `Chat` row: 10 → 20 commands; subcommand tags expanded to `message` `group` `search` `list-top-conversations`
|
||||
- `Bot` row: 6 → 7 commands; subcommand tags expanded with `create` `search-groups`
|
||||
- Total raised to **152 commands across 14 products**
|
||||
|
||||
## [1.0.12] - 2026-04-21
|
||||
|
||||
Product-surface expansion: first-class `doc` (DingTalk Docs) and `minutes` (AI Minutes) skill references, refreshed `aitable` guide aligned with the shipped binary (including dashboard / chart / export), and a README sync that brings the full command catalog to **141 commands across 14 products**.
|
||||
|
||||
### Added
|
||||
|
||||
- **`doc` skill reference** (`skills/references/products/doc.md`) — 16-command coverage of DingTalk Docs:
|
||||
- Discovery: `search`, `list`, `info`, `read`
|
||||
- Authoring: `create`, `update`, `folder create`
|
||||
- Files: `upload`, `download`
|
||||
- Block-level editing: block `query`, `insert`, `update`, `delete`
|
||||
- Comments: `comment list`, `create`, `reply`
|
||||
- URL → `doc_id` extraction rules and nodeId dual-format notes
|
||||
- **`minutes` skill reference** (`skills/references/products/minutes.md`) — coverage of AI Minutes:
|
||||
- Lists: personal / shared-with-me / all-accessible
|
||||
- Content: basic info, AI summary, keywords, transcription, extracted todos, batch detail
|
||||
- Editing: title update
|
||||
- Recording control: start, pause, resume, stop
|
||||
- **SKILL.md routing**:
|
||||
- Product overview table rows for `doc` and `minutes`
|
||||
- Intent decision tree routes — `钉钉文档/云文档/知识库/块级编辑/文档评论` → `doc`; `听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词` → `minutes`
|
||||
- Danger-op table entries: `doc delete`, `doc block delete`
|
||||
- `aitable` description completed with the `附件` (attachment) group
|
||||
- **`aitable` skill enhancements**:
|
||||
- `field create` single-field mode (`--name` / `--type` / `--config`) with examples
|
||||
- `base get` URL → `baseId` quick-tip
|
||||
- Dedicated "URL → baseId 提取" chapter
|
||||
- "`--filters` 筛选语法排错与使用规范" chapter
|
||||
- "相关产品" cross-link section pointing to `doc`
|
||||
- **"复杂操作" chapter** (#141) — dashboard / chart workflow (with two-call sequencing and `chart share get` vs `dashboard share get` error semantics) and two-stage `export data` polling (`scope=all/table/view` parameter constraints)
|
||||
- **README Key Services sync** (#140):
|
||||
- New rows: `doc` (16 commands), `minutes` (22 commands — adds `hot-word`, `mind-graph`, `replace-text`, `speaker`, `upload` subgroups)
|
||||
- `aitable` expanded from 20 → 37 commands; surfaces `chart`, `dashboard`, `export`, `import`, `view` subgroups
|
||||
- Total command count updated from **86 → 141 across 14 products**
|
||||
- "Coming soon" list drops `doc` and `minutes`
|
||||
|
||||
### Changed
|
||||
|
||||
- `aitable record query` docs rename `--keyword` → `--query` to match the shipped binary
|
||||
- `aitable record query` docs clarify `--sort` direction semantics (avoids misuse of `order`)
|
||||
- `aitable base list` guidance strengthened — "only for recent browsing; use `base search` for lookups"; intent decision prioritizes `base search` for base discovery
|
||||
|
||||
## [1.0.11] - 2026-04-20
|
||||
|
||||
Plugin subsystem hardening: faster cold startup, cleaner lifecycle, stricter isolation, and polished UX for PAT / i18n / error routing.
|
||||
|
||||
### Added
|
||||
|
||||
- `feat: supports claw-like products` — overlay path for Claw-style embedded editions
|
||||
- `feat(plugin): inject user identity (UserID, CorpID) into stdio plugin subprocesses`
|
||||
- `feat(auth): improve login UX for terminal auth denial cases` — clearer messaging + retry affordance
|
||||
- `feat: PAT scope error visualization and auto-retry with authorization polling` (#113)
|
||||
- Human-readable error output (lark-cli style) with type/message/hint/authorization command
|
||||
- JSON payload also available via `--format json`
|
||||
- Auto-retry once the user completes scope authorization
|
||||
|
||||
### Changed
|
||||
|
||||
- `perf(plugin): serve plugin MCP tool list from disk cache on startup` — hot path skips Initialize+ListTools when snapshot exists
|
||||
- `perf(plugin): parallelize all plugin discovery and tighten cold timeouts` — HTTP cold budget 4s → 700ms (auth) / 500ms (plain); stdio and HTTP fan out concurrently
|
||||
- `perf(plugin): share cache.Store across discovery` — single `*cache.Store` above the fan-out instead of per-goroutine instances
|
||||
- `refactor(plugin): remove default/managed plugin privileged mechanism` (#124) — third-party plugins install on an equal footing via `dws plugin install`
|
||||
- `refactor(plugin): purge removed plugin settings instead of merely disabling` — `RemovePlugin` now deletes `EnabledPlugins` and `PluginConfigs` entries
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix(transport): cap plugin MCP startup at ~4s when endpoints are unreachable` (#119) — eliminates the 10s `dws --help` stall caused by compounding transport timeouts
|
||||
- `fix(plugin): stop stdio child processes on exit and before removal` — no more orphaned plugin subprocesses
|
||||
- `fix(pat): avoid shared PAT command state in root registration` (#129)
|
||||
- `fix: -f json 模式下错误 JSON 从 stdout 改为输出到 stderr` (#133) — restores CI stderr-based failure assertions
|
||||
- `fix(cli): localize plugin/help command strings via i18n` (#118, #134) — zh locale now shows consistent Chinese `--help`; wraps plugin module, help command, and OAuth client-id/secret flag descriptions
|
||||
- `chore: remove workspace and bundled artifacts` (#127) — clean local-only repository leftovers
|
||||
|
||||
## [1.0.9] - 2026-04-16
|
||||
|
||||
Plugin system launch + execution-pipeline overhaul. This is the largest release since 1.0.0: third-party MCP servers become first-class commands, the command pipeline grows to five stages, and the edition overlay gains the hooks needed for embedded hosts.
|
||||
|
||||
### Added
|
||||
|
||||
#### Plugin system (new)
|
||||
|
||||
- `plugin` command family: `install`, `list`, `info`, `enable`, `disable`, `remove`, `create`, `dev`, `config set/get/list/unset`
|
||||
- Plugin manifest parsing/validation, managed/user directory-based identity
|
||||
- MCP server conversion and injection into the dynamic routing registry
|
||||
- Pipeline hook adapter for shell-based hooks
|
||||
- Stdio transport: subprocess lifecycle, `DWS_PLUGIN_ROOT` / `DWS_PLUGIN_DATA` variable expansion
|
||||
- Stdio server tools automatically registered as CLI subcommands (e.g. `dws hello greet --name Peter`)
|
||||
- Streamable-HTTP MCP tool discovery via `registerHTTPServer`
|
||||
- Updater: managed plugin update check on CLI startup (10 s timeout, best-effort)
|
||||
- `dws plugin create` scaffold (plugin.json, SKILL.md, hooks.json); `dws plugin dev` source-dir registration without copy
|
||||
- `SyncSkills` — copies plugin skills to agent directories on startup
|
||||
- **Auth Token Registry**: per-server HTTP headers declared in `plugin.json` for third-party MCP servers (e.g. Alibaba Cloud Bailian) independent from DingTalk OAuth
|
||||
- **Persistent plugin config** (`dws plugin config ...`): values persisted to `~/.dws/settings.json`, auto-injected as env vars; `${KEY}` in `plugin.json` resolves without manual `export`
|
||||
- **Build lifecycle**: `build` field compiles stdio servers to native binaries at install time
|
||||
- **Command-name conflict protection**: reserved built-in names (`auth`, `plugin`, `cache`, …) and plugin-vs-plugin duplicate detection
|
||||
- Parallel service discovery (`sync.WaitGroup`) — startup reduced from sequential `N*10s` to parallel `max(10s)`
|
||||
|
||||
#### Core commands & diagnostics
|
||||
|
||||
- `dws doctor` — one-stop environment/auth/network diagnostics
|
||||
- `dws config list` — centralized view of scattered configuration
|
||||
- Structured perf tracing (upgraded from debug tool to diagnostics output)
|
||||
- `feat(skill): restore find/get for legacy skill market API` — `skill find`, `skill get`; `skill add` still uses aihub download
|
||||
|
||||
#### Edition / overlay hooks
|
||||
|
||||
- `edition.Hooks.SaveToken` / `LoadToken` / `DeleteToken` — delegate token persistence with keychain fallback
|
||||
- `edition.Hooks.AuthClientID` / `AuthClientFromMCP` — overlay can override the OAuth client ID and route auth through MCP endpoints
|
||||
- `edition.Hooks.AfterPersistentPreRun` — wire non-MCP clients (e.g. A2A gateway) after root setup
|
||||
- `edition.Hooks.ClassifyToolResult` — custom MCP result classification before the default business-error detection
|
||||
- Token marker file (`token.json`) for embedded hosts to detect auth state without keychain access
|
||||
- `pkg/runtimetoken.ResolveAccessToken` mirroring MCP auth resolution; MCP identity headers exported via `pkg/cli` for auxiliary HTTP transports
|
||||
- `ExitCoder` interface — edition-specific errors carry custom exit codes
|
||||
- `RawStderrError` interface — errors that bypass CLI formatting and emit raw stderr (for desktop runtimes)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Command execution pipeline: 3 → 5 stages** (`Register → PreParse → PostParse → PreRequest → PostResponse`)
|
||||
- `feat(schema): return structured degraded errors instead of silent empty catalog` — new `CatalogDegraded` error with reasons `unauthenticated` / `market_unreachable` / `runtime_all_failed`; auth pre-check short-circuits doomed MCP connections
|
||||
- `refactor(auth): unify auxiliary token resolution with MCP cached path` — shared `resolveAccessTokenFromDir`; overlays reuse the process-level token cache
|
||||
- `feat(plugin): improve CLI overlay resolution and plugin install robustness`
|
||||
- `plugin.json` `cli` field now accepts a file path (e.g. `"cli": "overlay.json"`) in addition to inline JSON
|
||||
- `description` field on `CLIToolOverride` for static fallback when MCP `tools/list` is unavailable
|
||||
- Windows install uses `cmd /C` instead of `sh -c` for build commands
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix(plugin): harden plugin system security boundaries`
|
||||
- Reject `file://` / local paths in git URLs; allow only `https` / `ssh`
|
||||
- Reject symlink entries during ZIP extraction (path-traversal defense)
|
||||
- `build.output` must be a relative path within the plugin directory
|
||||
- Reject absolute paths in stdio command declarations
|
||||
- Block dangerous env var names (`PATH`, `LD_PRELOAD`, …) from plugin config injection
|
||||
- `fix(plugin): schema flag params, HTTP tool discovery, and integration tests`
|
||||
- `fix(plugin): skip min version check in dev mode`
|
||||
|
||||
## [1.0.8] - 2026-04-07
|
||||
|
||||
AITable command surface expansion, installer alignment with npm conventions, and execution-timeout hardening.
|
||||
|
||||
### Added
|
||||
|
||||
- **AITable static helper commands** (20 commands in total) replacing dynamic routing:
|
||||
- `base`: `list`, `search`, `get`, `create`, `update`
|
||||
- `table`: `get`, `create`, `update`
|
||||
- `field`: `get`, `create`, `update`
|
||||
- `record`: `query`, `create`, `update`
|
||||
- `template`: `search`
|
||||
- `attachment`: `upload`
|
||||
- `feat(install): align skill dirs with npm and add OpenClaw` — skill install paths follow npm conventions; OpenClaw added to supported agents
|
||||
- Label rendering optimization for AITable records (`to #73551688`)
|
||||
- README: npm install method documented
|
||||
- README: note that `dws upgrade` requires v1.0.7+
|
||||
|
||||
### Changed
|
||||
|
||||
- `perf: optimize command timeout handling, instrumentation, and diagnostics`
|
||||
|
||||
## [1.0.7] - 2026-04-02
|
||||
|
||||
Self-upgrade, edition overlay foundation, and fail-closed auth enforcement.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws upgrade`** — self-upgrade via GitHub Releases; atomic replace; cross-platform (macOS/Linux/Windows)
|
||||
- `feat: edition layer for Wukong overlay` — build-time edition hook lets downstream overlays customize auth UX, config dir, static server list, visible products, and extra root commands
|
||||
- `pkg/edition` defaults + `pkg/editiontest` contract tests
|
||||
- `Makefile` target `edition-test`; CI job `edition-tests`
|
||||
- Static server injection skips market discovery when configured
|
||||
- Deduplicates top-level commands so overlay wins
|
||||
- `hideNonDirectRuntimeCommands` respects edition `VisibleProducts`
|
||||
- Gated `auth login` subcommand + hints for embedded editions
|
||||
- Optional token auto-purge; edition `ConfigDir` override
|
||||
- `dws version` — human-readable multi-line output plus JSON with edition, architecture, build, commit
|
||||
- Tag reporting for case suites (`to #73551688`)
|
||||
- `feat(auth): unify MCP retry constant and add retry to remaining endpoints`
|
||||
|
||||
### Changed
|
||||
|
||||
- `style(auth): redesign OAuth authorization pages UI`
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix(auth): switch CLI auth check from fail-open to fail-closed`
|
||||
- When `/cli/cliAuthEnabled` is unreachable (network error/timeout/5xx), OAuth callback now routes to the permission request page instead of silently marking "enabled"
|
||||
- Device Flow blocks login and asks the user to verify network connectivity
|
||||
- `CheckCLIAuthEnabled` retries with backoff (3 attempts, 0s/1s/2s) to tolerate transient issues
|
||||
|
||||
## [1.0.6] - 2026-04-01
|
||||
|
||||
Error diagnostics overhaul, destructive-command confirmation, and credential auto-persistence.
|
||||
|
||||
### Added
|
||||
|
||||
- **Interactive confirmation for destructive dynamic commands** — prompts before delete/remove operations unless `--yes` is set
|
||||
- **Enhanced error diagnostics**
|
||||
- `ServerDiagnostics` struct extracts `trace_id`, `server_error_code`, `technical_detail`, `server_retryable` from MCP responses
|
||||
- Pulls diagnostics from JSON-RPC `error.data`, tool call result content, and HTTP headers (`X-Trace-Id`, `X-Request-Id`, `x-dingtalk-trace-id`)
|
||||
- Three verbosity levels for `PrintHuman`: Normal (trace ID + server code), Verbose (+ technical detail), Debug (+ RPC code / operation / reason)
|
||||
- Local logging now includes sanitized request body, response body on error, retry attempts, and classification events
|
||||
- `TruncateBody` / `SanitizeArguments` / `RedactHeaders` helpers with sensitive-key substring detection
|
||||
- **Auth credential persistence**
|
||||
- `feat(auth): enhance device flow with CLI auth check and admin guidance`
|
||||
- `feat(auth): persist OAuth credentials for reliable token refresh`
|
||||
- `feat(auth): persist client credentials and optimize keychain access` — auto-persist `--client-id` / `--client-secret`; keychain credential cache to avoid repeated reads; enhanced logout cleans `app.json` + keychain secrets + `token.json`
|
||||
- `add report helper with flexible date parsing and defaults`
|
||||
- `feat: to #73551688 支持消息通知`
|
||||
- README: Official App mode (recommended, direct login without creating an app) + Custom App mode; admin guide for enabling CLI access
|
||||
|
||||
### Changed
|
||||
|
||||
- Getting Started simplified with inline login commands; whitelist references removed from the IMPORTANT banner
|
||||
- Version bump documentation updated to v1.0.5 internal; co-creation group QR code refreshed
|
||||
|
||||
### Fixed
|
||||
|
||||
- `fix: resolve verbosity flag lookup, FileLogger lazy binding, and business error logging`
|
||||
- `resolveVerbosity` uses `cmd.Flags()` instead of `PersistentFlags()` so subcommands inherit `--verbose` / `--debug`
|
||||
- `FileLogger` lazy-binds in `executeInvocation` (after `configureLogLevel` init)
|
||||
- Business errors (HTTP 200 + `success=false`) now written to the file logger for offline diagnosis
|
||||
- OAuth callback race condition (write response before sending code)
|
||||
- `import path for errors package in skill_command.go`
|
||||
|
||||
## [1.0.4] - 2026-03-30
|
||||
|
||||
Token-refresh reliability and onboarding clarity.
|
||||
|
||||
### Added
|
||||
|
||||
- `feat(auth): persist client credentials for token refresh` — `--client-id` / `--client-secret` are stored for automatic refresh after expiration; client secret lives in the system Keychain with a file reference
|
||||
- README onboarding flow rewrite with step-by-step first-time setup and more realistic examples
|
||||
- Agent skill reference polish: clearer examples, updated intent routing patterns, expanded `simple.md` onboarding, cross-skill reference fixes
|
||||
|
||||
## [1.0.3] - 2026-03-29
|
||||
|
||||
Filtering power, schema rendering, and a native `todo` command family.
|
||||
|
||||
### Added
|
||||
|
||||
- **Nested / array-indexed output filtering**
|
||||
- `--fields` now accepts dot-notation (e.g. `--fields response.content`) and array index access (e.g. `response.items[0]`)
|
||||
- New field-path parser with recursive extraction logic
|
||||
- **`schema` command enhancements**
|
||||
- Table format output for human consumption
|
||||
- Product-level endpoint loading in the CLI loader
|
||||
- Schema-text rendering wired into the runner output pipeline
|
||||
- **`todo` task helper family** — static `create` / `update` / `done` / `get` / `delete` with `preferLegacyLeaf` replacing dynamic commands
|
||||
- MCP tool alignment: `create_personal_todo`, `update_todo_task`, `update_todo_done_status`, `query_todo_detail`, `delete_todo`
|
||||
- ISO-8601 due-time parsing
|
||||
- Hidden title aliases and delete confirmation
|
||||
- Priority field on `todo` helper
|
||||
- Expanded zh / en i18n coverage (fixes `en.json` spacing/wording issues)
|
||||
- README restructured with collapsible feature sections
|
||||
|
||||
## [1.0.2] - 2026-03-29
|
||||
|
||||
Deep workspace tooling upgrade: pipeline-based input correction, output filtering, enhanced stdin handling, and multi-endpoint routing.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
GO ?= go
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -34,6 +34,9 @@ policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
package:
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.1.0-red" alt="v1.1.0"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<img src=".github/badges/coverage.svg" alt="Coverage">
|
||||
</p>
|
||||
@@ -19,27 +19,28 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below.
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
|
||||
- [Why dws?](#why-dws)
|
||||
- [Installation](#installation)
|
||||
- [Upgrade](#upgrade)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Using with Agents](#using-with-agents)
|
||||
- [New Features](#new-features)
|
||||
- [Features](#features)
|
||||
- [Key Services](#key-services)
|
||||
- [Security by Design](#security-by-design)
|
||||
- [AI Agent Skills](#ai-agent-skills)
|
||||
- [Reference & Docs](#reference--docs)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
---
|
||||
|
||||
<h2 id="why-dws">Why dws?</h2>
|
||||
@@ -65,8 +66,19 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
<details>
|
||||
<summary>Other install methods</summary>
|
||||
|
||||
**npm** (requires Node.js (npm/npx)):
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
|
||||
|
||||
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
|
||||
> ```bash
|
||||
> xattr -d com.apple.quarantine /path/to/dws
|
||||
> ```
|
||||
|
||||
**Build from source**:
|
||||
|
||||
```bash
|
||||
@@ -80,110 +92,128 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
</details>
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
|
||||
dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
|
||||
|
||||
```bash
|
||||
dws upgrade # interactive upgrade to latest version
|
||||
dws upgrade --check # check for new versions without installing
|
||||
dws upgrade --list # list all available versions
|
||||
dws upgrade --version v1.0.7 # upgrade to a specific version
|
||||
dws upgrade --rollback # rollback to the previous version
|
||||
dws upgrade -y # skip confirmation prompt
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--check` | Check for updates without installing |
|
||||
| `--list` | List all available versions with changelogs |
|
||||
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
|
||||
| `--rollback` | Rollback to the previous backed-up version |
|
||||
| `--force` | Force reinstall even if already on the latest version |
|
||||
| `--skip-skills` | Skip skill package update |
|
||||
| `-y` | Skip confirmation prompt |
|
||||
|
||||
</details>
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Step 1: Create a DingTalk Application
|
||||
|
||||
Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**.
|
||||
|
||||
<details>
|
||||
<summary>View screenshot</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="Create Application" width="600">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
### Step 2: Configure Redirect URL
|
||||
|
||||
Go to app settings → **Security Settings**. Add the following redirect URLs and save:
|
||||
|
||||
```
|
||||
http://127.0.0.1
|
||||
https://login.dingtalk.com
|
||||
```bash
|
||||
dws auth login # browser opens automatically
|
||||
dws auth login --device # for headless environments (Docker, SSH, CI)
|
||||
```
|
||||
|
||||
> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
|
||||
Select your organization and authorize. That's it.
|
||||
|
||||
> If your organization hasn't enabled CLI access, you'll be prompted to send an access request to your admin. Once approved, re-run `dws auth login`.
|
||||
|
||||
<details>
|
||||
<summary>View screenshot</summary>
|
||||
<summary><strong>Organization hasn't enabled CLI access?</strong></summary>
|
||||
|
||||
1. After selecting your organization, click "Apply Now" to notify the admin
|
||||
2. The admin receives a request card and can approve with one click
|
||||
3. Once approved, re-run `dws auth login`
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="Configure Redirect URL" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="Apply for Access" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
### Step 3: Publish the Application
|
||||
|
||||
Click "App Release - Version Management & Release" to publish and go live.
|
||||
|
||||
<details>
|
||||
<summary>View screenshot</summary>
|
||||
<summary><strong>Admin: Enable CLI access for your organization</strong></summary>
|
||||
|
||||
Go to [Developer Platform](https://open-dev.dingtalk.com) → "CLI Access Management" → Enable.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="Publish Application" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI Access Management" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
### Step 4: Request Whitelist Access
|
||||
<details>
|
||||
<summary><strong>Custom App mode (CI/CD, ISV integration)</strong></summary>
|
||||
|
||||
Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
|
||||
For enterprise-managed scenarios, create your own DingTalk app:
|
||||
|
||||
### Step 5: Authenticate
|
||||
1. [Open Platform Console](https://open-dev.dingtalk.com/fe/app#/corp/app) → Create App
|
||||
2. Security Settings → Add redirect URLs: `http://127.0.0.1,https://login.dingtalk.com`
|
||||
3. Publish the app
|
||||
4. Login:
|
||||
|
||||
```bash
|
||||
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
```
|
||||
|
||||
Or via environment variables:
|
||||
Credentials are securely persisted after first login (Keychain). Subsequent runs auto-refresh tokens.
|
||||
|
||||
```bash
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
> CLI flags take precedence over environment variables. Credentials are used for DingTalk's OAuth device flow.
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" # search contacts
|
||||
dws calendar event list # list calendar events
|
||||
dws todo task create --title "Quarterly report" --executors "<userId>" # create a todo
|
||||
dws contact user search --query "engineering" # search contacts
|
||||
dws calendar event list # list today's calendar events
|
||||
dws doc search --query "quarterly" # search DingTalk Docs
|
||||
dws minutes list mine # list AI meeting notes I created
|
||||
dws drive list # list DingTalk drive files
|
||||
dws todo task create --title "Quarterly report" --executors "<your-userId>" # create a todo (replace <your-userId>)
|
||||
dws todo task list --dry-run # preview without executing
|
||||
```
|
||||
|
||||
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all commands with descriptions and when-to-use guidance.
|
||||
|
||||
## Using with Agents
|
||||
|
||||
dws is designed as an AI-native CLI. Here's how to integrate it with your agent:
|
||||
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
|
||||
|
||||
### 1. Setup
|
||||
|
||||
```bash
|
||||
# Install dws
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
|
||||
# Configure auth via environment variables (recommended for agents, no interactive login)
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
### 2. Agent Invocation Patterns
|
||||
### Agent Invocation Patterns
|
||||
|
||||
```bash
|
||||
# Use --yes to skip confirmation prompts (required for agents)
|
||||
dws todo task create --title "Review PR" --yes
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# Use --dry-run to preview operations (safe execution)
|
||||
dws contact user search --keyword "test" --dry-run
|
||||
dws contact user search --query "engineering" --dry-run
|
||||
|
||||
# Use --jq to extract precisely (save tokens)
|
||||
dws contact user search --keyword "test" --jq '.response.content'
|
||||
|
||||
# Use --fields to return only needed fields
|
||||
dws calendar event list --fields response
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### 3. Schema Discovery (Agent Self-Exploration)
|
||||
### Schema Discovery
|
||||
|
||||
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
|
||||
|
||||
@@ -192,15 +222,124 @@ Agents don't need pre-built knowledge of every command. Use `dws schema` to dyna
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
|
||||
# Step 2: Inspect target tool's parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.input_schema'
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
|
||||
# Step 3: Construct the correct call
|
||||
dws aitable record query --base-id "xxx" --table-id "yyy" --limit 10
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
### 4. Error Tolerance
|
||||
### Agent Skills
|
||||
|
||||
The smart correction engine is highly tolerant of common agent-generated mistakes:
|
||||
The repo ships a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
|
||||
**What's included:**
|
||||
|
||||
| Component | Path | Description |
|
||||
|-----------|------|-------------|
|
||||
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
|
||||
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
|
||||
| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `calendar_schedule_meeting.py` | Create event + add participants + find & book available meeting room |
|
||||
| `calendar_free_slot_finder.py` | Find common free slots across multiple people, recommend best meeting time |
|
||||
| `calendar_today_agenda.py` | View today/tomorrow/this week's schedule |
|
||||
| `import_records.py` | Batch import records from CSV/JSON into AITable |
|
||||
| `bulk_add_fields.py` | Batch add fields to an AITable data table |
|
||||
| `upload_attachment.py` | Upload attachment to AITable attachment field |
|
||||
| `todo_batch_create.py` | Batch create todos from JSON (with priority, due date, executors) |
|
||||
| `todo_daily_summary.py` | Summarize today/this week's incomplete todos |
|
||||
| `todo_overdue_check.py` | Scan overdue todos and output overdue list |
|
||||
| `contact_dept_members.py` | Search department by name and list all members |
|
||||
| `attendance_my_record.py` | View my attendance records for today/this week/specific date |
|
||||
| `attendance_team_shift.py` | Query team shift schedules and attendance statistics |
|
||||
| `report_inbox_today.py` | View today's received reports with details |
|
||||
|
||||
</details>
|
||||
|
||||
**ISV Integration**: Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**.
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
|
||||
|
||||
`dws api` lets you call any DingTalk OpenAPI without an SDK. Tokens are automatically acquired and refreshed.
|
||||
|
||||
> **Prerequisite**: Must login with your own app credentials (see [Custom App mode](#getting-started)). Encrypted tokens from MCP default-credential login are not supported for raw API calls.
|
||||
|
||||
```bash
|
||||
# Login (first time only)
|
||||
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
|
||||
|
||||
# === api.dingtalk.com ===
|
||||
|
||||
# List all enterprise apps
|
||||
dws api GET /v1.0/microApp/allApps
|
||||
|
||||
# Search users (POST + JSON body)
|
||||
dws api POST /v1.0/contact/users/search \
|
||||
--data '{"queryWord":"engineering","offset":0,"size":10}'
|
||||
|
||||
# === oapi.dingtalk.com ===
|
||||
|
||||
# Get user details (use --base-url to specify domain)
|
||||
dws api POST /topapi/v2/user/get \
|
||||
--base-url https://oapi.dingtalk.com \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# Or use the full URL directly
|
||||
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# === General ===
|
||||
dws api GET /v1.0/microApp/allApps --page-all # auto-paginate
|
||||
dws api GET /v1.0/microApp/allApps --dry-run # preview request
|
||||
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq filtering
|
||||
```
|
||||
|
||||
| Feature | Details |
|
||||
|---------|----------|
|
||||
| Dual-form auto-detection | Automatically selects api.dingtalk.com (header auth) or oapi.dingtalk.com (query-param auth) based on URL |
|
||||
| Automatic token management | App-level accessToken is fetched on first call, cached while valid, auto-refreshed on expiry |
|
||||
| Domain allowlist | Only `api.dingtalk.com` and `oapi.dingtalk.com` permitted — prevents token leakage |
|
||||
| Auto-pagination | `--page-all` iterates all pages. `--page-limit` caps the maximum (default 10, set to 0 for unlimited, hard cap at 500 to prevent infinite loops) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes</summary>
|
||||
|
||||
Built-in pipeline engine that normalizes flag names, splits sticky arguments, and fuzzy-matches typos:
|
||||
|
||||
```bash
|
||||
# Naming convention auto-conversion (camelCase / snake_case / UPPER -> kebab-case)
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
|
||||
|
||||
# Sticky argument splitting
|
||||
dws contact user search --query "engineering" --timeout30 # auto-split to --timeout 30
|
||||
|
||||
# Fuzzy flag name matching
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
|
||||
|
||||
# Value normalization (boolean / number / date / enum)
|
||||
# "yes" -> true, "1,000" -> 1000, "2024/03/29" -> "2024-03-29", "ACTIVE" -> "active"
|
||||
```
|
||||
|
||||
| Agent Output | dws Auto-Corrects To |
|
||||
|-----------|--------------|
|
||||
@@ -210,83 +349,88 @@ The smart correction engine is highly tolerant of common agent-generated mistake
|
||||
| `--USER-ID` | `--user-id` |
|
||||
| `--user_name` | `--user-name` |
|
||||
|
||||
### 5. Agent Skill Integration
|
||||
</details>
|
||||
|
||||
After installing Agent Skills, tools like Claude Code / Cursor can use DingTalk capabilities directly:
|
||||
<details>
|
||||
<summary><strong>jq Filtering & Field Selection</strong> — fine-grained output control to reduce token consumption</summary>
|
||||
|
||||
```bash
|
||||
# Install skills into current project
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> See the [AI Agent Skills](#ai-agent-skills) section for details.
|
||||
|
||||
## New Features
|
||||
|
||||
### Smart Input Correction
|
||||
|
||||
dws includes a built-in pipeline engine that auto-corrects common AI model parameter mistakes:
|
||||
|
||||
```bash
|
||||
# Naming convention auto-conversion (camelCase / snake_case / UPPER -> kebab-case)
|
||||
dws aitable record query --baseId x --tableId y # auto-corrected to --base-id --table-id
|
||||
|
||||
# Sticky argument splitting
|
||||
dws contact user search --keyword "test" --timeout30 # auto-split to --timeout 30
|
||||
|
||||
# Fuzzy flag name matching
|
||||
dws aitable record query --base-id x --tabel-id y # --tabel-id -> --table-id
|
||||
|
||||
# Value normalization (boolean / number / date / enum)
|
||||
# "yes" -> true, "1,000" -> 1000, "2024/03/29" -> "2024-03-29", "ACTIVE" -> "active"
|
||||
```
|
||||
|
||||
### JSON Field Selection & jq Filtering
|
||||
|
||||
Fine-grained output control to reduce token consumption:
|
||||
|
||||
```bash
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id x --table-id y --fields invocation,response
|
||||
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id x --table-id y --jq '.invocation.params'
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
```
|
||||
|
||||
### Schema Introspection
|
||||
</details>
|
||||
|
||||
Agents can query parameter schemas before making calls:
|
||||
<details>
|
||||
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
|
||||
|
||||
```bash
|
||||
dws schema # list all products and tools
|
||||
dws schema aitable.query_records # view parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.input_schema.required' # view required fields
|
||||
dws schema aitable.query_records --jq '.tool.required' # view required fields
|
||||
dws schema --jq '.products[].id' # extract all product IDs
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Pipe & File Input</strong> — read flag values from files or stdin</summary>
|
||||
|
||||
```bash
|
||||
# Read message body from a file
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @report.md
|
||||
|
||||
# Pipe content via stdin
|
||||
cat report.md | dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report"
|
||||
|
||||
# Read from stdin explicitly
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @-
|
||||
```
|
||||
|
||||
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
|
||||
|
||||
</details>
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Description |
|
||||
|---------|---------|-------------|
|
||||
| Contact | `contact` | Users / departments |
|
||||
| Chat | `chat` | Group management / members / bot messaging / webhook |
|
||||
| Calendar | `calendar` | Events / meeting rooms / free-busy |
|
||||
| Todo | `todo` | Task management |
|
||||
| Approval | `approval` | Processes / forms / instances |
|
||||
| Attendance | `attendance` | Clock-in / shifts / statistics |
|
||||
| Ding | `ding` | DING messages / send / recall |
|
||||
| Report | `report` | Reports / templates / statistics |
|
||||
| AITable | `aitable` | AI table operations |
|
||||
| Workbench | `workbench` | App query |
|
||||
| DevDoc | `devdoc` | Open platform docs search |
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat / IM | `chat` (alias `im`) | 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
|
||||
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AI Tables | `aitable` | 42 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); templates |
|
||||
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email |
|
||||
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
|
||||
| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| AI App | `aiapp` | 3 | — | 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 |
|
||||
|
||||
Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
> **213 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.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
|
||||
`conference` (video meetings)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -333,25 +477,9 @@ Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
|
||||
> Found a vulnerability? Report via [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new). See [SECURITY.md](./SECURITY.md).
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. The install script deploys them to `~/.agents/skills/dws` automatically.
|
||||
|
||||
```bash
|
||||
# Install skills into the current project only
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
|
||||
### ISV Skill Integration
|
||||
|
||||
Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**.
|
||||
|
||||
**Example**: A CRM Skill invokes the Calendar Skill to create a client meeting, then triggers the Todo Skill to assign follow-ups — the AI agent completes cross-system collaboration in a single conversation.
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [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
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
+271
-143
@@ -9,7 +9,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.1.0-red" alt="v1.1.0"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
|
||||
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<img src=".github/badges/coverage.svg" alt="Coverage">
|
||||
</p>
|
||||
@@ -19,27 +19,28 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>目录</strong></summary>
|
||||
|
||||
- [为什么选择 dws?](#why-dws)
|
||||
- [安装](#安装)
|
||||
- [升级](#升级)
|
||||
- [开始使用](#开始使用)
|
||||
- [快速开始](#快速开始)
|
||||
- [在 Agent 中使用](#在-agent-中使用)
|
||||
- [新功能](#新功能)
|
||||
- [功能特性](#功能特性)
|
||||
- [核心服务](#核心服务)
|
||||
- [安全设计](#安全设计)
|
||||
- [AI Agent Skills](#ai-agent-skills)
|
||||
- [参考与文档](#参考与文档)
|
||||
- [贡献指南](#贡献指南)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
---
|
||||
|
||||
<h2 id="why-dws">为什么选择 dws?</h2>
|
||||
@@ -65,8 +66,19 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
<details>
|
||||
<summary>其他安装方式</summary>
|
||||
|
||||
**npm**(需要 Node.js(npm/npx)):
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
|
||||
|
||||
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
|
||||
> ```bash
|
||||
> xattr -d com.apple.quarantine /path/to/dws
|
||||
> ```
|
||||
|
||||
**从源码构建**:
|
||||
|
||||
```bash
|
||||
@@ -80,110 +92,128 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
</details>
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
|
||||
dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
|
||||
|
||||
```bash
|
||||
dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
升级过程采用两阶段原子流程,确保一致性:
|
||||
|
||||
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
|
||||
|
||||
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--check` | 仅检查更新,不安装 |
|
||||
| `--list` | 列出所有可用版本及更新日志 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7`) |
|
||||
| `--rollback` | 回滚到上一个备份版本 |
|
||||
| `--force` | 强制重新安装,即使已是最新版本 |
|
||||
| `--skip-skills` | 跳过技能包更新 |
|
||||
| `-y` | 跳过确认提示 |
|
||||
|
||||
</details>
|
||||
|
||||
## 开始使用
|
||||
|
||||
### 步骤 1:创建钉钉应用
|
||||
|
||||
进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。
|
||||
|
||||
<details>
|
||||
<summary>查看截图</summary>
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="创建应用" width="600">
|
||||
</p>
|
||||
</details>
|
||||
|
||||
### 步骤 2:配置重定向 URL
|
||||
|
||||
进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
|
||||
|
||||
```
|
||||
http://127.0.0.1
|
||||
https://login.dingtalk.com
|
||||
```bash
|
||||
dws auth login # 自动唤起浏览器
|
||||
dws auth login --device # 无浏览器环境(Docker、SSH、CI)
|
||||
```
|
||||
|
||||
> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
|
||||
选择组织并授权即可。
|
||||
|
||||
> 如果组织尚未开启 CLI 访问权限,系统会引导你向管理员发送申请。审批通过后重新执行 `dws auth login` 即可。
|
||||
|
||||
<details>
|
||||
<summary>查看截图</summary>
|
||||
<summary><strong>组织未开启 CLI 访问权限?</strong></summary>
|
||||
|
||||
1. 选择组织后,点击「立即申请」通知管理员
|
||||
2. 管理员收到申请卡片,一键审批
|
||||
3. 审批通过后,重新执行 `dws auth login`
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="配置重定向URL" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="申请权限" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
### 步骤 3:发布应用
|
||||
|
||||
点击「应用发布 - 版本管理与发布」,发布版本使应用上线。
|
||||
|
||||
<details>
|
||||
<summary>查看截图</summary>
|
||||
<summary><strong>管理员:为组织开启 CLI 访问权限</strong></summary>
|
||||
|
||||
进入 [开发者平台](https://open-dev.dingtalk.com) →「CLI 访问管理」→ 开启。
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="发布应用" width="600">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI访问管理" width="600">
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
### 步骤 4:申请白名单
|
||||
<details>
|
||||
<summary><strong>自建应用模式(CI/CD、ISV 集成)</strong></summary>
|
||||
|
||||
加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。
|
||||
企业自主管控场景,可创建自有钉钉应用:
|
||||
|
||||
### 步骤 5:登录认证
|
||||
1. [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app#/corp/app) → 创建应用
|
||||
2. 安全设置 → 添加重定向 URL:`http://127.0.0.1,https://login.dingtalk.com`
|
||||
3. 发布应用
|
||||
4. 登录:
|
||||
|
||||
```bash
|
||||
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
```
|
||||
|
||||
或通过环境变量:
|
||||
首次登录后凭证安全存储(Keychain),后续自动刷新 Token。
|
||||
|
||||
```bash
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
> CLI 参数优先于环境变量。凭证用于钉钉 OAuth 设备流认证。
|
||||
</details>
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "悟空" # 搜索联系人
|
||||
dws calendar event list # 查看日历日程
|
||||
dws todo task create --title "季度汇报" --executors "<userId>" # 创建待办
|
||||
dws contact user search --query "悟空" # 搜索联系人
|
||||
dws calendar event list # 查看今天的日程
|
||||
dws doc search --query "季度" # 搜索钉钉文档
|
||||
dws minutes list mine # 列出我创建的 AI 听记
|
||||
dws drive list # 列出钉盘文件
|
||||
dws todo task create --title "季度汇报" --executors "<your-userId>" # 创建待办(请替换为真实 userId)
|
||||
dws todo task list --dry-run # 预览操作但不执行
|
||||
```
|
||||
|
||||
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部命令,带描述和使用场景。
|
||||
|
||||
## 在 Agent 中使用
|
||||
|
||||
dws 是为 AI Agent 设计的 CLI 工具。以下是接入指南。
|
||||
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
|
||||
|
||||
### 1. 环境配置
|
||||
|
||||
```bash
|
||||
# 安装 dws
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
|
||||
# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录)
|
||||
export DWS_CLIENT_ID=<your-app-key>
|
||||
export DWS_CLIENT_SECRET=<your-app-secret>
|
||||
dws auth login
|
||||
```
|
||||
|
||||
### 2. Agent 调用模式
|
||||
### Agent 调用模式
|
||||
|
||||
```bash
|
||||
# 使用 --yes 跳过确认提示(Agent 必须)
|
||||
dws todo task create --title "Review PR" --yes
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# 使用 --dry-run 预览操作(安全执行)
|
||||
dws contact user search --keyword "test" --dry-run
|
||||
dws contact user search --query "张三" --dry-run
|
||||
|
||||
# 使用 --jq 精确提取(节省 token)
|
||||
dws contact user search --keyword "test" --jq '.response.content'
|
||||
|
||||
# 使用 --fields 只返回需要的字段
|
||||
dws calendar event list --fields response
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### 3. Schema 发现(Agent 自主探索)
|
||||
### Schema 发现
|
||||
|
||||
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
|
||||
|
||||
@@ -192,15 +222,124 @@ Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
|
||||
# 第二步:查看目标工具的参数结构
|
||||
dws schema aitable.query_records --jq '.tool.input_schema'
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
|
||||
# 第三步:构造正确的调用
|
||||
dws aitable record query --base-id "xxx" --table-id "yyy" --limit 10
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
### 4. 错误容忍
|
||||
### Agent Skills
|
||||
|
||||
dws 的智能纠错引擎对 Agent 生成的常见错误有高容忍度:
|
||||
仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
|
||||
**包含内容:**
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
|
||||
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
|
||||
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
|
||||
|
||||
| 脚本 | 说明 |
|
||||
|------|------|
|
||||
| `calendar_schedule_meeting.py` | 一键创建日程 + 添加参与者 + 搜索并预定空闲会议室 |
|
||||
| `calendar_free_slot_finder.py` | 查询多人共同空闲时段,推荐最佳会议时间 |
|
||||
| `calendar_today_agenda.py` | 查看今天/明天/本周的日程安排 |
|
||||
| `import_records.py` | 从 CSV/JSON 批量导入记录到 AI 表格 |
|
||||
| `bulk_add_fields.py` | 批量添加字段到 AI 表格数据表 |
|
||||
| `upload_attachment.py` | 上传附件到 AI 表格 attachment 字段 |
|
||||
| `todo_batch_create.py` | 从 JSON 文件批量创建待办(含优先级、截止时间、执行者) |
|
||||
| `todo_daily_summary.py` | 汇总今天/本周未完成的待办 |
|
||||
| `todo_overdue_check.py` | 扫描已过截止时间但未完成的待办,输出逾期清单 |
|
||||
| `contact_dept_members.py` | 按部门名称搜索并列出所有成员 |
|
||||
| `attendance_my_record.py` | 查看我今天/本周/指定日期的考勤记录 |
|
||||
| `attendance_team_shift.py` | 查询团队成员本周排班和出勤统计 |
|
||||
| `report_inbox_today.py` | 查看今天收到的日志列表及详情 |
|
||||
|
||||
</details>
|
||||
|
||||
**ISV 集成**:编写您自己的 Agent Skill,与 dws 内置 Skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。
|
||||
|
||||
## 功能特性
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
|
||||
|
||||
`dws api` 让你直接调用任意钉钉 OpenAPI,无需 SDK,Token 自动获取和刷新。
|
||||
|
||||
> **前置条件**:必须使用自有应用凭证登录(见[自建应用模式](#开始使用))。通过 MCP 默认凭证登录 不支持 raw API 调用。
|
||||
|
||||
```bash
|
||||
# 登录(仅首次)
|
||||
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
|
||||
|
||||
# === api.dingtalk.com ===
|
||||
|
||||
# 获取企业所有应用列表
|
||||
dws api GET /v1.0/microApp/allApps
|
||||
|
||||
# 搜索用户 (POST + JSON body)
|
||||
dws api POST /v1.0/contact/users/search \
|
||||
--data '{"queryWord":"张三","offset":0,"size":10}'
|
||||
|
||||
# === oapi.dingtalk.com ===
|
||||
|
||||
# 获取用户详情(使用 --base-url 指定域名)
|
||||
dws api POST /topapi/v2/user/get \
|
||||
--base-url https://oapi.dingtalk.com \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# 也可以直接使用完整 URL
|
||||
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# === 通用功能 ===
|
||||
dws api GET /v1.0/microApp/allApps --page-all # 自动翻页
|
||||
dws api GET /v1.0/microApp/allApps --dry-run # 预览请求
|
||||
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
|
||||
```
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 双形态自动识别 | 根据 URL 自动选择 api.dingtalk.com(Header 认证)或 oapi.dingtalk.com(Query 参数认证) |
|
||||
| Token 自动管理 | 首次调用自动获取应用级 accessToken,有效期内缓存,过期自动刷新 |
|
||||
| 域名白名单 | 仅允许 `api.dingtalk.com` 和 `oapi.dingtalk.com`,防止 Token 泄露 |
|
||||
| 自动分页 | `--page-all` 自动遍历所有分页。`--page-limit` 控制翻页上限(默认 10,设为 0 不限制,硬上限 500 防止死循环) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误</summary>
|
||||
|
||||
内置 Pipeline 纠错引擎,支持命名风格转换、粘连参数拆分、拼写模糊匹配:
|
||||
|
||||
```bash
|
||||
# 命名风格自动转换 (camelCase / snake_case / UPPER → kebab-case)
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
|
||||
|
||||
# 粘连参数自动拆分
|
||||
dws contact user search --query "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
|
||||
# 拼写错误模糊匹配
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
|
||||
|
||||
# 参数值归一化 (布尔 / 数字 / 日期 / 枚举)
|
||||
# "yes" → true, "1,000" → 1000, "2024/03/29" → "2024-03-29", "ACTIVE" → "active"
|
||||
```
|
||||
|
||||
| Agent 输出 | dws 自动纠正为 |
|
||||
|-----------|--------------|
|
||||
@@ -210,83 +349,88 @@ dws 的智能纠错引擎对 Agent 生成的常见错误有高容忍度:
|
||||
| `--USER-ID` | `--user-id` |
|
||||
| `--user_name` | `--user-name` |
|
||||
|
||||
### 5. Agent Skill 集成
|
||||
</details>
|
||||
|
||||
安装 Agent Skills 后,Claude Code / Cursor 等工具可直接使用钉钉能力:
|
||||
<details>
|
||||
<summary><strong>jq 过滤 & 字段筛选</strong> — 精确控制输出,减少 token 消耗</summary>
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 详见 [AI Agent Skills](#ai-agent-skills) 章节。
|
||||
|
||||
## 新功能
|
||||
|
||||
### 智能输入纠错
|
||||
|
||||
dws 内置 Pipeline 纠错引擎,自动修正 AI 模型常见的参数错误,无需人工干预:
|
||||
|
||||
```bash
|
||||
# 命名风格自动转换 (camelCase / snake_case / UPPER → kebab-case)
|
||||
dws aitable record query --baseId x --tableId y # 自动纠正为 --base-id --table-id
|
||||
|
||||
# 粘连参数自动拆分
|
||||
dws contact user search --keyword "test" --timeout30 # 自动拆分为 --timeout 30
|
||||
|
||||
# 拼写错误模糊匹配
|
||||
dws aitable record query --base-id x --tabel-id y # --tabel-id → --table-id
|
||||
|
||||
# 参数值归一化 (布尔 / 数字 / 日期 / 枚举)
|
||||
# "yes" → true, "1,000" → 1000, "2024/03/29" → "2024-03-29", "ACTIVE" → "active"
|
||||
```
|
||||
|
||||
### JSON 字段筛选 & jq 过滤
|
||||
|
||||
精确控制输出内容,减少 token 消耗:
|
||||
|
||||
```bash
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id x --table-id y --fields invocation,response
|
||||
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id x --table-id y --jq '.invocation.params'
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
```
|
||||
|
||||
### Schema 自省
|
||||
</details>
|
||||
|
||||
Agent 可以在调用前查询任何工具的参数结构:
|
||||
<details>
|
||||
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
|
||||
|
||||
```bash
|
||||
dws schema # 列出所有产品和工具
|
||||
dws schema aitable.query_records # 查看参数 Schema
|
||||
dws schema aitable.query_records --jq '.tool.input_schema.required' # 查看必填字段
|
||||
dws schema aitable.query_records --jq '.tool.required' # 查看必填字段
|
||||
dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>管道 & 文件输入</strong> — 从文件或 stdin 读取 flag 值</summary>
|
||||
|
||||
```bash
|
||||
# 从文件读取消息内容
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @report.md
|
||||
|
||||
# 通过管道传入内容
|
||||
cat report.md | dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报"
|
||||
|
||||
# 显式从 stdin 读取
|
||||
dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @-
|
||||
```
|
||||
|
||||
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
|
||||
|
||||
</details>
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 描述 |
|
||||
|---------|---------|-------------|
|
||||
| 通讯录 | `contact` | 用户 / 部门 |
|
||||
| 群聊 | `chat` | 群管理 / 群成员 / 机器人消息 / Webhook |
|
||||
| 日历 | `calendar` | 日程 / 会议室 / 闲忙 |
|
||||
| 待办 | `todo` | 任务管理 |
|
||||
| 审批 | `approval` | 流程 / 表单 / 实例 |
|
||||
| 考勤 | `attendance` | 打卡 / 排班 / 统计 |
|
||||
| DING | `ding` | DING 消息 / 发送 / 撤回 |
|
||||
| 日志 | `report` | 日志 / 模版 / 统计 |
|
||||
| 智能表格 | `aitable` | AI 表格操作 |
|
||||
| 工作台 | `workbench` | 应用查询 |
|
||||
| 开发者文档 | `devdoc` | 开放平台文档搜索 |
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
|
||||
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
|
||||
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
|
||||
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
|
||||
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
|
||||
| AI 表格 | `aitable` | 42 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);模板 |
|
||||
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
|
||||
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
|
||||
| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
|
||||
> **19 个产品,213 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
|
||||
`conference`(视频会议)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -335,25 +479,9 @@ dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
|
||||
> 发现安全漏洞?请通过 [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new) 报告,详见 [SECURITY.md](./SECURITY.md)。
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
仓库为每个钉钉产品提供 Agent Skill(`SKILL.md`),安装脚本会自动部署到 `~/.agents/skills/dws`。
|
||||
|
||||
```bash
|
||||
# 仅安装 skills 到当前项目
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
|
||||
### ISV Skill 联调
|
||||
|
||||
编写您自己的 Agent Skill,与 dws 内置 skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。
|
||||
|
||||
**示例**:CRM Skill 调用日历 Skill 为客户创建会议,再通过待办 Skill 分配跟进任务 — AI Agent 在一次对话中完成跨系统协作。
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
@@ -52,6 +52,8 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
|
||||
@@ -7,6 +7,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const childProcess = require("child_process");
|
||||
|
||||
// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync.
|
||||
const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
@@ -20,6 +21,8 @@ const AGENT_DIRS = [
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
];
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# dws Command Index
|
||||
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
|
||||
- **Products**: 13
|
||||
- **Total commands**: 159
|
||||
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Edit `pre/config.json`, not this file.
|
||||
|
||||
## Global flags
|
||||
|
||||
Every command inherits these flags (documented here once, not repeated per command):
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--client-id` | Override OAuth client ID (DingTalk AppKey) |
|
||||
| `--client-secret` | Override OAuth client secret (DingTalk AppSecret) |
|
||||
| `--debug` | Enable debug logging |
|
||||
| `--dry-run` | Preview the request without executing |
|
||||
| `--fields` | Comma-separated output field projection |
|
||||
| `-f, --format` | Output format: `json` \| `table` \| `raw` (default `json`) |
|
||||
| `--jq` | jq expression applied to JSON output |
|
||||
| `--mock` | Return mock data (developer aid) |
|
||||
| `-o, --output` | Write output to a file |
|
||||
| `--timeout` | HTTP request timeout in seconds (default 30) |
|
||||
| `--token` | Override the configured API token |
|
||||
| `-v, --verbose` | Verbose logging |
|
||||
| `-y, --yes` | Skip confirmation prompts (AI-agent mode) |
|
||||
|
||||
## Contents
|
||||
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 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
|
||||
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
|
||||
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
|
||||
- [`dws report` — Reports](#dws-report) · 7 commands
|
||||
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
|
||||
|
||||
## `dws aitable` — AI Tables
|
||||
|
||||
_AI-powered spreadsheet (Base) with datasheets, fields, records, views, dashboards, charts, import/export, attachments, and templates._
|
||||
|
||||
**41 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws aitable attachment upload` | Request an upload ticket for attaching a file to an AI table attachment-type field. Returns an upload URL and token the caller uses to stream the file. | When the agent needs to attach binary assets (images, PDFs, etc.) to records before creating or updating an attachment field value. |
|
||||
| `dws aitable base create` | Create a new AI table (Base) under the current user's workspace. Returns the newly-created Base ID. | When an agent needs to provision a fresh Base before populating datasheets, fields, and records. |
|
||||
| `dws aitable base delete` | Permanently delete an existing AI table (Base) by ID, removing all its datasheets, views, and records. | When the agent is cleaning up a Base that is no longer needed or was created for a one-off task. |
|
||||
| `dws aitable base get` | Retrieve metadata for a single AI table (Base), including name, owner, and structural summary. | When the agent needs to inspect a specific Base before performing further operations on it. |
|
||||
| `dws aitable base list` | List AI tables (Bases) accessible to the current user, paginated. | When the agent needs to enumerate the user's Bases to pick one by name or index. |
|
||||
| `dws aitable base search` | Search AI tables (Bases) the current user can access by keyword against the Base name. | When the agent knows a partial Base name and needs to resolve it to a Base ID. |
|
||||
| `dws aitable base update` | Update mutable properties of an AI table (Base), such as its name or icon. | When the agent needs to rename or rebrand an existing Base without touching its data. |
|
||||
| `dws aitable chart create` | Create a new chart inside a Base, bound to a datasheet and view with a given configuration. | When the agent is building analytics on top of a datasheet and needs to materialize a chart visualization. |
|
||||
| `dws aitable chart delete` | Delete a chart from a Base by chart ID. | When the agent needs to remove an obsolete or mistakenly-created chart. |
|
||||
| `dws aitable chart get` | Retrieve a chart's full configuration and metadata. | When the agent needs to inspect an existing chart to clone it or adjust its configuration. |
|
||||
| `dws aitable chart share get` | Retrieve the current public-sharing configuration of a chart, including share link and permissions. | When the agent needs to check whether a chart is already shared externally before issuing a link. |
|
||||
| `dws aitable chart share update` | Enable, disable, or update the public-sharing configuration of a chart. | When the agent needs to generate or revoke an external share link for a chart. |
|
||||
| `dws aitable chart update` | Update an existing chart's configuration (type, dimensions, metrics, style). | When the agent iterates on a chart's visualization after reviewing the initial result. |
|
||||
| `dws aitable chart widgets-example` | Return a reference JSON example of chart widget configuration accepted by chart create/update. | When the agent needs a schema template before composing chart configuration payloads. |
|
||||
| `dws aitable dashboard config-example` | Return a reference JSON example of dashboard configuration accepted by dashboard create/update. | When the agent needs a schema template before composing dashboard layout payloads. |
|
||||
| `dws aitable dashboard create` | Create a new dashboard inside a Base with a layout of chart widgets. | When the agent wants to group multiple charts into a single dashboard view for a report or overview page. |
|
||||
| `dws aitable dashboard delete` | Delete a dashboard from a Base by dashboard ID. | When the agent is removing an outdated dashboard. |
|
||||
| `dws aitable dashboard get` | Retrieve a dashboard's layout, widget list, and metadata. | When the agent needs to inspect a dashboard before updating it or cloning it. |
|
||||
| `dws aitable dashboard share get` | Retrieve the current public-sharing configuration of a dashboard. | When the agent needs to verify whether a dashboard has an active external share link. |
|
||||
| `dws aitable dashboard share update` | Enable, disable, or update the public-sharing configuration of a dashboard. | When the agent needs to generate or revoke an external share link for a dashboard. |
|
||||
| `dws aitable dashboard update` | Update an existing dashboard's layout, widgets, or metadata. | When the agent adds, removes, or rearranges charts on an existing dashboard. |
|
||||
| `dws aitable export data` | Export data from a datasheet (optionally scoped to a view) to a downloadable file such as Excel or CSV. | When the agent needs to hand off Base data to an external system or deliver it as an attachment. |
|
||||
| `dws aitable field create` | Create one or more fields in a datasheet with specified types and options. | When the agent is extending a datasheet's schema to capture new attributes. |
|
||||
| `dws aitable field delete` | Delete a field from a datasheet by field ID; all values in that column are removed. | When the agent is cleaning up unused or deprecated columns in a datasheet. |
|
||||
| `dws aitable field get` | Retrieve field definitions for a datasheet, including type, options, and order. | When the agent needs the field schema before constructing record payloads or queries. |
|
||||
| `dws aitable field update` | Update a field's name, type, or options in a datasheet. | When the agent needs to rename a column or change its type/options without recreating it. |
|
||||
| `dws aitable import data` | Import previously-uploaded data (e.g. Excel) into a datasheet as records, optionally creating fields. | When the agent is bulk-loading external data into a Base after a successful import upload. |
|
||||
| `dws aitable import upload` | Request an upload ticket for an import file (Excel/CSV) to be staged before calling import data. | When the agent needs to push a local dataset into a Base and must first stage the file. |
|
||||
| `dws aitable record create` | Insert one or more records into a datasheet with given field values. | When the agent needs to add new rows to a datasheet, individually or in batches. |
|
||||
| `dws aitable record delete` | Delete one or more records from a datasheet by record ID. | When the agent removes rows that are obsolete or were created in error. |
|
||||
| `dws aitable record query` | Query records from a datasheet with optional filters, sort, view scoping, and pagination. | When the agent needs to read row data to reason about it, render it, or feed it into downstream logic. |
|
||||
| `dws aitable record update` | Update field values on one or more existing records by record ID. | When the agent modifies specific row values after reading or computing new data. |
|
||||
| `dws aitable table create` | Create a new datasheet (table) inside a Base. | When the agent needs another table alongside existing ones in the same Base. |
|
||||
| `dws aitable table delete` | Delete a datasheet from a Base by table ID, removing all its records, views, and fields. | When the agent is disposing of a datasheet that is no longer needed. |
|
||||
| `dws aitable table get` | List datasheets within a Base, returning table IDs and names. | When the agent needs to resolve a table name to an ID inside a known Base. |
|
||||
| `dws aitable table update` | Update a datasheet's name or other metadata. | When the agent needs to rename a datasheet without altering its contents. |
|
||||
| `dws aitable template search` | Search the AI table template gallery by keyword. | When the agent needs to suggest or bootstrap from an existing Base template rather than building from scratch. |
|
||||
| `dws aitable view create` | Create a new view (grid, gallery, kanban, etc.) on a datasheet. | When the agent needs an alternate filtered/sorted presentation of the same datasheet data. |
|
||||
| `dws aitable view delete` | Delete a view from a datasheet by view ID. | When the agent is cleaning up unused views. |
|
||||
| `dws aitable view get` | Retrieve view definitions for a datasheet, including filter, sort, and visible-field configuration. | When the agent needs to understand or reuse a view's configuration before querying records through it. |
|
||||
| `dws aitable view update` | Update a view's name, filter, sort, grouping, or visible fields. | When the agent refines an existing view's configuration after inspection. |
|
||||
|
||||
## `dws attendance` — Attendance
|
||||
|
||||
_Attendance check-in records, shifts, and aggregate statistics._
|
||||
|
||||
**4 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws attendance record get` | Query a user's detailed clock-in/clock-out attendance records for a given time range. | When the agent needs to verify punctuality, pull attendance evidence, or build an attendance report for an individual. |
|
||||
| `dws attendance rules` | Query the attendance group the user belongs to along with its attendance rules (schedule, locations, shifts). | When the agent needs to know the user's expected work schedule or attendance policies before interpreting records. |
|
||||
| `dws attendance shift list` | Batch-query the assigned shifts for a set of employees over a date range. | When the agent needs to plan around team shifts or compile a shift-based roster. |
|
||||
| `dws attendance summary` | Retrieve an aggregated attendance summary for a single user (totals of late, early-leave, absence, overtime). | When the agent needs a quick attendance health check without pulling raw records. |
|
||||
|
||||
## `dws calendar` — Calendar
|
||||
|
||||
_Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
**14 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws calendar busy search` | Query the busy/free time windows of one or more users over a given range. | When the agent is scheduling a meeting and needs to find a slot where all attendees are free. |
|
||||
| `dws calendar event create` | Create a new calendar event on the user's calendar with title, time, attendees, and optional meeting room. | When the agent schedules a meeting or reminder on behalf of the user. |
|
||||
| `dws calendar event delete` | Delete an existing calendar event by event ID. | When the agent cancels a previously scheduled event. |
|
||||
| `dws calendar event get` | Retrieve the full details of a calendar event, including participants, location, and body. | When the agent needs to inspect an event before updating or referencing it. |
|
||||
| `dws calendar event list` | List calendar events on the user's calendar within a given time range. | When the agent needs an overview of the user's upcoming schedule or a day's agenda. |
|
||||
| `dws calendar event suggest` | Suggest candidate meeting time slots based on participants' busy/free data and constraints. | When the agent is coordinating a meeting and wants ranked time suggestions rather than raw busy data. |
|
||||
| `dws calendar event update` | Update an existing calendar event's fields such as time, title, participants, or location. | When the agent needs to reschedule or amend a previously created event. |
|
||||
| `dws calendar participant add` | Add one or more participants to an existing calendar event. | When the agent invites additional attendees after the event has been created. |
|
||||
| `dws calendar participant delete` | Remove one or more participants from an existing calendar event. | When the agent drops attendees who no longer need to join the event. |
|
||||
| `dws calendar participant list` | List current participants of a calendar event along with their response status. | When the agent needs to check who is attending before sending follow-up reminders. |
|
||||
| `dws calendar room add` | Book a specific meeting room onto an existing calendar event. | When the agent needs to attach a physical meeting room to an already-scheduled event. |
|
||||
| `dws calendar room delete` | Release a previously booked meeting room from a calendar event. | When the agent cancels or changes the room on an existing event. |
|
||||
| `dws calendar room list-groups` | List meeting room groups (usually by building or floor) available to the user. | When the agent is narrowing down rooms by location before running an availability search. |
|
||||
| `dws calendar room search` | Search meeting rooms by keyword within a group, optionally filtering to rooms free during a given window via `--available`. | When the agent needs to find a suitable room, typically free at a specific time, prior to booking. |
|
||||
|
||||
## `dws chat` — Group Chat / IM
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
| `dws chat group members add-bot` | Add a robot (bot) to an existing group chat so the bot can post messages there. | When the agent needs to enable bot-driven notifications in a group that does not yet contain the bot. |
|
||||
| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. |
|
||||
| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. |
|
||||
| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. |
|
||||
| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. |
|
||||
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
|
||||
| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. |
|
||||
| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. |
|
||||
| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. |
|
||||
| `dws chat message list-topic-replies` | Pull replies under a specific group topic thread. | When the agent needs the conversation tree of a threaded discussion rather than the flat message list. |
|
||||
| `dws chat message list-unread-conversations` | Fetch the list of conversations that currently have unread messages for the user. | When the agent builds a "catch me up" triage view of what still needs reading. |
|
||||
| `dws chat message recall-by-bot` | Recall (retract) a message previously sent by a robot in a group chat. | When the agent sent a bot message in error or with incorrect content and needs to withdraw it. |
|
||||
| `dws chat message search` | Search messages by keyword across the user's conversations. | When the agent needs to locate a specific statement or link the user remembers from chat history. |
|
||||
| `dws chat message send` | Send a message into a group chat or single chat as the authenticated user. | When the agent needs to relay a response to a user or notify a group on behalf of the human operator. |
|
||||
| `dws chat message send-by-bot` | Send a group message as a specific robot (bot) the user owns. | When the agent posts automated notifications under a bot identity rather than as the user. |
|
||||
| `dws chat message send-by-webhook` | Send a group message via a custom-robot incoming webhook URL. | When the agent needs to post to a group using a webhook without requiring full bot-permission setup. |
|
||||
| `dws chat search` | Search group conversations the user belongs to by group name keyword. | When the agent needs to resolve a group name to a conversation ID. |
|
||||
| `dws chat search-common` | Find group chats the current user and a specified other user both belong to. | When the agent needs an existing shared channel to contact another user without creating a new group. |
|
||||
|
||||
## `dws contact` — Contact Directory
|
||||
|
||||
_Users, departments, and directory lookups._
|
||||
|
||||
**6 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws contact dept list-members` | List members of a specific department by department ID. | When the agent needs the roster of a department to target communication or build a team overview. |
|
||||
| `dws contact dept search` | Search departments in the organization's contact directory by keyword. | When the agent needs to resolve a department name to a department ID. |
|
||||
| `dws contact user get` | Batch-fetch detailed profile information for one or more users by user ID. | When the agent needs names, titles, emails, or departments for a known set of user IDs. |
|
||||
| `dws contact user get-self` | Retrieve the profile of the currently authenticated user. | When the agent needs to identify who it is acting on behalf of (user ID, name, org). |
|
||||
| `dws contact user search` | Search users in the contact directory by keyword (name, title, etc.). | When the agent needs to resolve a person's display name to a user ID. |
|
||||
| `dws contact user search-mobile` | Look up a user by mobile phone number. | When the agent has only a phone number and needs to find the corresponding DingTalk user. |
|
||||
|
||||
## `dws devdoc` — Open Platform Docs
|
||||
|
||||
_Search the DingTalk Open Platform documentation._
|
||||
|
||||
**1 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 ding` — DING Messages
|
||||
|
||||
_Send and recall DING messages (priority notifications)._
|
||||
|
||||
**2 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws ding message recall` | Recall (retract) a previously sent DING message. | When the agent sent a DING in error and must withdraw it before recipients act on it. |
|
||||
| `dws ding message send` | Send a DING message (high-priority notification) to one or more recipients via app/SMS/phone. | When the agent needs to page recipients with urgency beyond a normal chat message. |
|
||||
|
||||
## `dws doc` — DingTalk Doc
|
||||
|
||||
_DingTalk Doc: search, browse, read/write, upload/download, files, folders, blocks, comments._
|
||||
|
||||
**21 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws doc block delete` | Delete a block from a DingTalk Doc by block ID. | When the agent is editing a document and needs to remove a specific paragraph, table, or other block. |
|
||||
| `dws doc block insert` | Insert a new block (paragraph, table, image, etc.) into a DingTalk Doc at a given position. | When the agent is programmatically assembling or editing a document's content. |
|
||||
| `dws doc block list` | List the blocks of a DingTalk Doc with their IDs, types, and content. | When the agent needs the structured block tree of a doc before modifying specific blocks. |
|
||||
| `dws doc block update` | Update the content or properties of an existing block in a DingTalk Doc. | When the agent amends a specific paragraph or element without rewriting the whole document. |
|
||||
| `dws doc comment create` | Create a document-level comment on a DingTalk Doc. | When the agent leaves feedback or follow-up notes that apply to the entire document. |
|
||||
| `dws doc comment create-inline` | Create an inline (anchored) comment on a specific text range within a DingTalk Doc. | When the agent needs to attach feedback to a particular passage rather than the whole doc. |
|
||||
| `dws doc comment list` | List comments on a DingTalk Doc, including replies. | When the agent is reviewing outstanding feedback or summarizing comment threads. |
|
||||
| `dws doc comment reply` | Reply to an existing comment on a DingTalk Doc. | When the agent responds to a reviewer's comment inline rather than starting a new thread. |
|
||||
| `dws doc copy` | Copy an existing DingTalk Doc or file to a specified destination folder. | When the agent needs to duplicate a template document into a new location for reuse. |
|
||||
| `dws doc create` | Create a new DingTalk Doc (document type) in a target folder or knowledge base. | When the agent needs a fresh DingTalk Doc to write into. |
|
||||
| `dws doc download` | Download a DingTalk Doc or file to a local path. | When the agent needs the raw file locally for processing or attachment. |
|
||||
| `dws doc file create` | Create a new file node of a given type (doc, sheet, mind map, whiteboard, AI table, etc.) in a target folder. | When the agent provisions any non-plain-document file type inside DingTalk Docs. |
|
||||
| `dws doc folder create` | Create a new folder inside a DingTalk Docs knowledge base or drive location. | When the agent organizes output into a fresh folder before writing files into it. |
|
||||
| `dws doc info` | Retrieve metadata for a document or file (title, type, owner, path, permissions). | When the agent needs descriptive info about a node without fetching its full content. |
|
||||
| `dws doc list` | List the child nodes (files and subfolders) of a folder or knowledge base. | When the agent traverses the document hierarchy to find or enumerate items. |
|
||||
| `dws doc move` | Move a DingTalk Doc or file to a different folder location. | When the agent reorganizes document structure. |
|
||||
| `dws doc read` | Read the content of a DingTalk Doc as Markdown. | When the agent needs the document body as text for summarization, Q&A, or further editing. |
|
||||
| `dws doc rename` | Rename a DingTalk Doc or file. | When the agent needs to change a document's title without altering its contents or location. |
|
||||
| `dws doc search` | Search DingTalk Docs the user can access by keyword. | When the agent needs to locate a document by title or content before reading or editing it. |
|
||||
| `dws doc update` | Update the content of a DingTalk Doc (bulk content rewrite rather than block-level edit). | When the agent has freshly generated content and needs to overwrite a doc's body. |
|
||||
| `dws doc upload` | Obtain upload credentials and URL for uploading a local file as an attachment into DingTalk Docs or a knowledge base. | When the agent needs to stage a local file for attachment into the DingTalk Docs system. |
|
||||
|
||||
## `dws drive` — DingTalk Drive
|
||||
|
||||
_DingTalk Drive file and folder management._
|
||||
|
||||
**6 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws drive commit` | Commit a file upload to DingTalk Drive after the binary has been pushed to the presigned URL. | When the agent finalizes a Drive upload step; pairs with `drive upload-info`. |
|
||||
| `dws drive download` | Fetch a temporary download URL for a file stored in DingTalk Drive. | When the agent needs to retrieve a Drive-hosted file for local use or for handing to another service. |
|
||||
| `dws drive info` | Retrieve metadata for a file or folder in DingTalk Drive. | When the agent inspects a Drive node before downloading, moving, or listing around it. |
|
||||
| `dws drive list` | List the files and subfolders of a DingTalk Drive folder. | When the agent needs to enumerate Drive contents to find or pick items. |
|
||||
| `dws drive mkdir` | Create a new folder in DingTalk Drive. | When the agent organizes Drive output into a fresh folder before uploading files. |
|
||||
| `dws drive upload-info` | Obtain a presigned upload URL and token for pushing a local file into DingTalk Drive. | When the agent starts a Drive upload; pairs with `drive commit` to finalize. |
|
||||
|
||||
## `dws minutes` — AI Minutes
|
||||
|
||||
_AI meeting notes: listing, summary, todos, transcription, recording control, mind maps, speakers, hot words, uploads._
|
||||
|
||||
**19 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws minutes get batch` | Batch-fetch detailed metadata for multiple meeting notes (AI minutes) by ID. | When the agent needs to enrich a list of minutes IDs with titles, durations, and participants in one call. |
|
||||
| `dws minutes get info` | Retrieve basic metadata for a single meeting note (title, owner, time, duration, participants). | When the agent needs a header view of a specific meeting note. |
|
||||
| `dws minutes get keywords` | Retrieve the extracted keywords of a meeting note. | When the agent needs topical tags for a meeting without pulling the full transcript or summary. |
|
||||
| `dws minutes get summary` | Retrieve the AI-generated summary of a meeting note. | When the agent needs a concise recap of a meeting for reporting or follow-up. |
|
||||
| `dws minutes get todos` | Retrieve the action items (todos) extracted from a meeting note. | When the agent needs to convert meeting action items into tasks or follow up on commitments. |
|
||||
| `dws minutes get transcription` | Retrieve the raw speech-to-text transcription of a meeting note. | When the agent needs the full verbatim transcript for deep analysis or quoting. |
|
||||
| `dws minutes hot-word add` | Add a custom personal hot word to improve future speech-recognition accuracy on the user's minutes. | When the user has domain-specific jargon or proper nouns that the ASR model mistranscribes. |
|
||||
| `dws minutes list all` | List all meeting notes the user has access to, filterable by keyword and time range. | When the agent needs a broad search across the user's full minutes library. |
|
||||
| `dws minutes list mine` | List only the meeting notes the current user created. | When the agent scopes results to the user's own recordings rather than shared ones. |
|
||||
| `dws minutes list shared` | List meeting notes that have been shared with the current user by others. | When the agent wants to surface meetings the user is an invited viewer of. |
|
||||
| `dws minutes mind-graph create` | Generate a mind map from a meeting note asynchronously. | When the agent wants a structured mind-map visualization of a meeting's content. |
|
||||
| `dws minutes mind-graph status` | Query the generation status of a mind-map job and fetch the result when ready. | When the agent polls after `mind-graph create` to retrieve the finished mind map. |
|
||||
| `dws minutes replace-text` | Find and replace matching text across a meeting note's transcript paragraphs and summary. | When the agent corrects a systemic transcription mistake (e.g. wrong product name) throughout a note. |
|
||||
| `dws minutes speaker replace` | Reassign speaker labels in a meeting note (e.g. map "Speaker 1" to a specific user). | When the agent cleans up speaker diarization after automatic labels came out wrong. |
|
||||
| `dws minutes update summary` | Overwrite the summary content of a meeting note. | When the agent refines or replaces the AI-generated summary with a corrected or customized version. |
|
||||
| `dws minutes update title` | Update the title of a meeting note. | When the agent renames a meeting note for clarity before sharing or archiving. |
|
||||
| `dws minutes upload cancel` | Cancel an in-progress meeting-note file upload session. | When the agent aborts a multi-step upload due to user cancellation or upstream error. |
|
||||
| `dws minutes upload complete` | Complete an upload session and create a meeting note from the uploaded audio/video. | When the agent finalizes a minutes upload, triggering transcription and AI processing. |
|
||||
| `dws minutes upload create` | Create a file upload session for producing a meeting note from a local audio/video file. | When the agent begins uploading a recording to be turned into a meeting note. |
|
||||
|
||||
## `dws oa` — OA Approval
|
||||
|
||||
_OA approval workflows: list, approve, reject, revoke, records._
|
||||
|
||||
**9 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
|
||||
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
|
||||
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
|
||||
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
|
||||
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
|
||||
| `dws oa approval records` | Retrieve the operation history (who approved/commented/transferred, when) of an approval instance. | When the agent explains an approval's progression or audits who handled it. |
|
||||
| `dws oa approval reject` | Reject a pending approval process instance as the current user. | When the agent declines an approval on behalf of the user, optionally with a reason. |
|
||||
| `dws oa approval revoke` | Revoke an approval process instance previously initiated by the current user. | When the agent withdraws an approval request the user no longer wants to pursue. |
|
||||
| `dws oa approval tasks` | List pending approval task IDs assigned to the current user, used to drive approve/reject actions. | When the agent needs task IDs (not just instance IDs) before calling approve/reject. |
|
||||
|
||||
## `dws report` — Reports
|
||||
|
||||
_DingTalk Report feature: templates, entries, and statistics._
|
||||
|
||||
**7 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws report create` | Create a new report (DingTalk "Report" entry) based on a report template with filled-in content. | When the agent submits a daily/weekly report on behalf of the user. |
|
||||
| `dws report detail` | Retrieve the full details of a specific report entry, including fields and recipients. | When the agent needs to read a report's content for summarization or follow-up. |
|
||||
| `dws report list` | List reports the current user has received from others. | When the agent digests the user's incoming reports (e.g. team members' weeklies). |
|
||||
| `dws report sent` | List reports the current user has created and sent out. | When the agent reviews the user's own reporting history. |
|
||||
| `dws report stats` | Retrieve aggregated statistics for a report entry by ID (views, likes, comments, etc.). | When the agent measures engagement or reach of a report the user sent. |
|
||||
| `dws report template detail` | Retrieve the detailed schema of a report template by name, including required fields. | When the agent needs to know a template's field structure before calling `report create`. |
|
||||
| `dws report template list` | List the report templates the current user is allowed to use. | When the agent picks the correct report template (e.g. "weekly", "daily") before creating a report. |
|
||||
|
||||
## `dws todo` — Todo Tasks
|
||||
|
||||
_Personal todo task management._
|
||||
|
||||
**6 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws todo task create` | Create a personal todo item for the current user with title, due time, and optional executors. | When the agent captures an action item as a tracked todo in the user's DingTalk todo list. |
|
||||
| `dws todo task delete` | Delete a todo item by ID. | When the agent removes a todo that is no longer relevant. |
|
||||
| `dws todo task done` | Update the completion status of a todo's executor (mark done or undone). | When the agent marks an action item as completed after confirming the work is finished. |
|
||||
| `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. |
|
||||
|
||||
+48
-5
@@ -10,6 +10,7 @@
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
|
||||
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
|
||||
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
|
||||
|
||||
## Exit Codes / 退出码
|
||||
|
||||
@@ -19,8 +20,9 @@
|
||||
| 1 | API | MCP tool call or upstream API failure / MCP 工具调用或上游 API 失败 |
|
||||
| 2 | Auth | Authentication or authorization failure / 身份认证或授权失败 |
|
||||
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
|
||||
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现失败 |
|
||||
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
|
||||
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
|
||||
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
|
||||
|
||||
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
|
||||
|
||||
@@ -29,9 +31,10 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
## Output Formats / 输出格式
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -43,7 +46,47 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
|
||||
## Output to File / 输出到文件
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" -o result.json
|
||||
dws contact user search --query "Alice" -o result.json
|
||||
```
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
|
||||
|
||||
### 路径写法
|
||||
|
||||
```bash
|
||||
dws schema # 列出所有产品 + 工具名
|
||||
dws schema ding.send_ding_message # canonical: product.rpc_name
|
||||
dws schema ding.message.send # CLI 点路径: product.group.cli_name
|
||||
dws schema "ding message send" # CLI 空格路径(同上)
|
||||
dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
|
||||
dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
|
||||
```
|
||||
|
||||
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
|
||||
|
||||
### 单工具输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
|
||||
| `group` | CLI 父级 group 路径(dot-separated) |
|
||||
| `title` / `description` | 工具名/说明(overlay 优先) |
|
||||
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
|
||||
| `output_schema` | MCP 输出 Schema(上游下发时才有) |
|
||||
| `sensitive` | 敏感写操作,需 `--yes` 确认 |
|
||||
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
|
||||
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
|
||||
|
||||
**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
|
||||
|
||||
### 筛选输出
|
||||
|
||||
```bash
|
||||
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
|
||||
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
|
||||
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
|
||||
```
|
||||
|
||||
## Shell Completion / 自动补全
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.8
|
||||
require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -15,7 +16,6 @@ require (
|
||||
require (
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/itchyny/gojq v0.12.18 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package apiclient provides a lightweight HTTP client for calling DingTalk
|
||||
// OpenAPI (https://api.dingtalk.com) directly, bypassing the MCP JSON-RPC
|
||||
// transport. It is used exclusively by the `dws api` command.
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBaseURL is the DingTalk new-style OpenAPI base URL.
|
||||
DefaultBaseURL = "https://api.dingtalk.com"
|
||||
|
||||
// LegacyBaseURL is the DingTalk legacy (oapi) API base URL.
|
||||
LegacyBaseURL = "https://oapi.dingtalk.com"
|
||||
|
||||
// AuthHeader is the new-style OpenAPI authentication header.
|
||||
AuthHeader = "x-acs-dingtalk-access-token"
|
||||
|
||||
// LegacyAuthParam is the query parameter used for legacy API authentication.
|
||||
LegacyAuthParam = "access_token"
|
||||
)
|
||||
|
||||
// AllowedMethods is the set of HTTP methods permitted for raw API calls.
|
||||
var AllowedMethods = map[string]bool{
|
||||
"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
|
||||
}
|
||||
|
||||
// RawAPIRequest describes a raw API request to DingTalk OpenAPI.
|
||||
type RawAPIRequest struct {
|
||||
Method string // GET, POST, PUT, PATCH, DELETE
|
||||
Path string // /v1.0/calendar/events or full URL
|
||||
Params map[string]any // query parameters
|
||||
Data any // request body (JSON), nil for GET
|
||||
}
|
||||
|
||||
// RawAPIResponse encapsulates the raw HTTP response.
|
||||
type RawAPIResponse struct {
|
||||
StatusCode int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// APIClient wraps an HTTP client for DingTalk OpenAPI calls.
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Token string
|
||||
}
|
||||
|
||||
// NewClient creates an APIClient with sensible defaults.
|
||||
func NewClient(token, baseURL string) *APIClient {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return &APIClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: defaultTransport(),
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Do sends a raw API request and returns the response.
|
||||
func (c *APIClient) Do(ctx context.Context, req RawAPIRequest) (*RawAPIResponse, error) {
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
||||
if !AllowedMethods[method] {
|
||||
return nil, fmt.Errorf("unsupported HTTP method: %s (allowed: GET, POST, PUT, PATCH, DELETE)", req.Method)
|
||||
}
|
||||
|
||||
fullURL, err := c.buildURL(req.Path, req.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building request URL: %w", err)
|
||||
}
|
||||
|
||||
// Security: verify target host before sending token.
|
||||
if err := ValidateTargetHost(fullURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Data != nil && method != "GET" {
|
||||
data, marshalErr := json.Marshal(req.Data)
|
||||
if marshalErr != nil {
|
||||
return nil, fmt.Errorf("marshaling request body: %w", marshalErr)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers and auth based on API style.
|
||||
if IsLegacyAPI(fullURL) {
|
||||
// Legacy API: token goes in query parameter.
|
||||
parsed, _ := url.Parse(fullURL)
|
||||
q := parsed.Query()
|
||||
q.Set(LegacyAuthParam, c.Token)
|
||||
parsed.RawQuery = q.Encode()
|
||||
httpReq.URL = parsed
|
||||
} else {
|
||||
// New API: token goes in header.
|
||||
httpReq.Header.Set(AuthHeader, c.Token)
|
||||
}
|
||||
if bodyReader != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "dws-cli/raw-api")
|
||||
|
||||
resp, err := c.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing HTTP request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
return &RawAPIResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Header: resp.Header,
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildURL constructs the full request URL from path and query params.
|
||||
func (c *APIClient) buildURL(path string, params map[string]any) (string, error) {
|
||||
normalised := NormalisePath(path, c.BaseURL)
|
||||
parsed, err := url.Parse(normalised)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing URL %q: %w", normalised, err)
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
q := parsed.Query()
|
||||
for k, v := range params {
|
||||
q.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
parsed.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// IsLegacyAPI returns true if the URL targets the legacy oapi.dingtalk.com endpoint.
|
||||
// Legacy APIs use query-parameter authentication instead of header-based auth.
|
||||
func IsLegacyAPI(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "oapi.dingtalk.com") ||
|
||||
strings.HasPrefix(lower, LegacyBaseURL)
|
||||
}
|
||||
|
||||
// NormalisePath normalises an API path:
|
||||
// - Full URLs are accepted as-is (after stripping query/fragment)
|
||||
// - Relative paths are prefixed with the base URL
|
||||
// - Query strings and fragments are stripped (must use --params)
|
||||
func NormalisePath(path, baseURL string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
// Strip query and fragment to force --params usage.
|
||||
if idx := strings.IndexAny(path, "?#"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
|
||||
// Full URL: extract the path portion relative to the base.
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
|
||||
// Ensure leading slash.
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + path
|
||||
}
|
||||
|
||||
// defaultTransport returns a tuned http.Transport matching the project conventions.
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewClient_DefaultBaseURL(t *testing.T) {
|
||||
c := NewClient("tok", "")
|
||||
if c.BaseURL != DefaultBaseURL {
|
||||
t.Errorf("expected %q, got %q", DefaultBaseURL, c.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_CustomBaseURL(t *testing.T) {
|
||||
c := NewClient("tok", "https://custom.api.com/")
|
||||
if c.BaseURL != "https://custom.api.com" {
|
||||
t.Errorf("expected trailing slash stripped, got %q", c.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path, base, want string
|
||||
}{
|
||||
{"/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"https://api.dingtalk.com/v1.0/users", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"/v1.0/users?foo=bar#frag", "", "https://api.dingtalk.com/v1.0/users"},
|
||||
{"/v1.0/users", "https://custom.example.com", "https://custom.example.com/v1.0/users"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalisePath(tt.path, tt.base)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_Success(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(AuthHeader) != "test-token" {
|
||||
t.Errorf("expected auth header %q, got %q", "test-token", r.Header.Get(AuthHeader))
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]string{"name": "test"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("test-token", srv.URL)
|
||||
resp, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "GET",
|
||||
Path: "/v1.0/test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_PostWithBody(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected JSON content type")
|
||||
}
|
||||
var body map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["key"] != "value" {
|
||||
t.Errorf("expected body key=value, got %v", body)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
resp, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "POST",
|
||||
Path: "/v1.0/test",
|
||||
Data: map[string]string{"key": "value"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_InvalidMethod(t *testing.T) {
|
||||
c := NewClient("tok", "")
|
||||
_, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "INVALID",
|
||||
Path: "/test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_QueryParams(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("pageSize") != "10" {
|
||||
t.Errorf("expected pageSize=10, got %v", r.URL.Query())
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("tok", srv.URL)
|
||||
_, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "GET",
|
||||
Path: "/v1.0/test",
|
||||
Params: map[string]any{"pageSize": 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLegacyAPI(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"https://api.dingtalk.com/v1.0/users", false},
|
||||
{"https://oapi.dingtalk.com/topapi/v2/user/get", true},
|
||||
{"https://OAPI.DINGTALK.COM/topapi/v2/user/get", true},
|
||||
{"https://custom.example.com/api", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := IsLegacyAPI(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsLegacyAPI(%q) = %v, want %v", tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDo_LegacyAPI_TokenInQueryParam(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Legacy API: token should be in query param.
|
||||
if r.URL.Query().Get(LegacyAuthParam) != "legacy-token" {
|
||||
t.Errorf("expected access_token=legacy-token in query, got %v", r.URL.Query())
|
||||
}
|
||||
// Should NOT have the new-style auth header.
|
||||
if r.Header.Get(AuthHeader) != "" {
|
||||
t.Errorf("expected no auth header for legacy API, got %q", r.Header.Get(AuthHeader))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"userid":"user1"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Use full URL with oapi.dingtalk.com in the path, but redirect to test server.
|
||||
// Since we can't DNS-resolve oapi.dingtalk.com, we use the test server URL
|
||||
// and pass the full oapi URL as Path so that NormalisePath preserves it.
|
||||
// Then we override the resolved URL in the client to point to our test server.
|
||||
//
|
||||
// Best approach: directly verify that buildURL + IsLegacyAPI routing works
|
||||
// by testing buildURL output and calling Do with a custom transport that
|
||||
// redirects oapi.dingtalk.com to our test server.
|
||||
c := NewClient("legacy-token", "")
|
||||
// Replace the transport to redirect oapi.dingtalk.com to test server.
|
||||
c.HTTPClient.Transport = &legacyTestTransport{targetURL: srv.URL}
|
||||
|
||||
resp, err := c.Do(context.Background(), RawAPIRequest{
|
||||
Method: "POST",
|
||||
Path: "https://oapi.dingtalk.com/topapi/v2/user/get",
|
||||
Data: map[string]string{"userid": "user1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyTestTransport redirects requests from oapi.dingtalk.com to a local test server.
|
||||
type legacyTestTransport struct {
|
||||
targetURL string
|
||||
}
|
||||
|
||||
func (t *legacyTestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Rewrite the host to point to our test server, preserving path and query.
|
||||
newURL := t.targetURL + req.URL.Path
|
||||
if req.URL.RawQuery != "" {
|
||||
newURL += "?" + req.URL.RawQuery
|
||||
}
|
||||
parsed, _ := url.Parse(newURL)
|
||||
req.URL = parsed
|
||||
req.Host = parsed.Host
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func TestNormalisePath_Legacy(t *testing.T) {
|
||||
tests := []struct {
|
||||
path, base, want string
|
||||
}{
|
||||
// Legacy full URL preserved.
|
||||
{"https://oapi.dingtalk.com/topapi/v2/user/get", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
|
||||
// Relative path with legacy base URL.
|
||||
{"/topapi/v2/user/get", LegacyBaseURL, "https://oapi.dingtalk.com/topapi/v2/user/get"},
|
||||
// Strip query from legacy URL.
|
||||
{"https://oapi.dingtalk.com/topapi/v2/user/get?access_token=xxx", "", "https://oapi.dingtalk.com/topapi/v2/user/get"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := NormalisePath(tt.path, tt.base)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalisePath(%q, %q) = %q, want %q", tt.path, tt.base, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePageLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
raw, want int
|
||||
}{
|
||||
// 0 → unlimited → safety cap
|
||||
{0, MaxPageLimit},
|
||||
// normal usage
|
||||
{3, 3},
|
||||
// default
|
||||
{10, 10},
|
||||
// within cap
|
||||
{100, 100},
|
||||
// exactly cap
|
||||
{MaxPageLimit, MaxPageLimit},
|
||||
// exceeds cap
|
||||
{MaxPageLimit + 100, MaxPageLimit},
|
||||
// negative → default
|
||||
{-1, DefaultPageLimit},
|
||||
{-100, DefaultPageLimit},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := resolvePageLimit(tt.raw)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolvePageLimit(%d) = %d, want %d", tt.raw, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAll_ProgressLog(t *testing.T) {
|
||||
AllowedHosts["127.0.0.1"] = true
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if callCount >= 3 {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{"has_more": false, "items": []any{1, 2}},
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{
|
||||
"has_more": true,
|
||||
"next_cursor": 100,
|
||||
"items": []any{callCount},
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("test-token", srv.URL)
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
pages, err := c.PaginateAll(context.Background(), RawAPIRequest{
|
||||
Method: "GET",
|
||||
Path: "/v1.0/test",
|
||||
}, PaginationOptions{
|
||||
PageLimit: 5,
|
||||
PageDelay: 0,
|
||||
LogWriter: &logBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(pages) != 3 {
|
||||
t.Errorf("expected 3 pages, got %d", len(pages))
|
||||
}
|
||||
|
||||
log := logBuf.String()
|
||||
if !strings.Contains(log, "第 1 页") || !strings.Contains(log, "第 2 页") || !strings.Contains(log, "第 3 页") {
|
||||
t.Errorf("expected progress log for each page, got: %s", log)
|
||||
}
|
||||
if !strings.Contains(log, "数据获取完成") {
|
||||
t.Errorf("expected completion message, got: %s", log)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaskToken returns a masked version of a token for display in dry-run
|
||||
// and log output. Shows the first 4 characters followed by "****".
|
||||
func MaskToken(token string) string {
|
||||
if len(token) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return token[:4] + "****"
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a dry-run preview of the API request that would be sent.
|
||||
func PrintDryRun(w io.Writer, req RawAPIRequest, baseURL, token string) error {
|
||||
fullURL := NormalisePath(req.Path, baseURL)
|
||||
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Method:", strings.ToUpper(req.Method))
|
||||
fmt.Fprintf(w, "%-12s%s\n", "URL:", fullURL)
|
||||
|
||||
if len(req.Params) > 0 {
|
||||
paramsJSON, err := json.MarshalIndent(req.Params, " ", " ")
|
||||
if err == nil {
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Params:", string(paramsJSON))
|
||||
}
|
||||
}
|
||||
|
||||
if req.Data != nil {
|
||||
dataJSON, err := json.MarshalIndent(req.Data, " ", " ")
|
||||
if err == nil {
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Body:", string(dataJSON))
|
||||
}
|
||||
}
|
||||
|
||||
if IsLegacyAPI(fullURL) {
|
||||
fmt.Fprintf(w, "%-12s%s=%s\n", "Auth:", LegacyAuthParam, MaskToken(token))
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Style:", "旧版 (oapi.dingtalk.com)")
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-12s%s: %s\n", "Auth:", AuthHeader, MaskToken(token))
|
||||
fmt.Fprintf(w, "%-12s%s\n", "Style:", "新版 (api.dingtalk.com)")
|
||||
}
|
||||
fmt.Fprintln(w, "===============")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPageLimit is the maximum number of pages fetched with --page-all
|
||||
// when --page-limit is not explicitly set.
|
||||
DefaultPageLimit = 10
|
||||
|
||||
// MaxPageLimit is the hard safety cap to prevent infinite loops when an
|
||||
// API endpoint has a bug that causes has_more to never become false.
|
||||
// Use --page-limit 0 to hit this cap; any explicit positive value is
|
||||
// honoured up to this ceiling.
|
||||
MaxPageLimit = 500
|
||||
|
||||
// DefaultPageDelay is the delay between paginated requests in milliseconds.
|
||||
DefaultPageDelay = 200
|
||||
)
|
||||
|
||||
// PaginationOptions controls automatic pagination behaviour.
|
||||
type PaginationOptions struct {
|
||||
PageLimit int // Maximum pages (0 = unlimited, capped at MaxPageLimit)
|
||||
PageDelay int // Delay between pages in milliseconds
|
||||
LogWriter io.Writer // Optional: progress log output (typically stderr)
|
||||
}
|
||||
|
||||
// PaginateAll fetches all pages of a paginated API and merges the results.
|
||||
// DingTalk APIs use two pagination patterns:
|
||||
// - cursor/next_cursor/has_more (in response body)
|
||||
// - next_token (in response body)
|
||||
//
|
||||
// The function auto-detects which pattern the API uses.
|
||||
func (c *APIClient) PaginateAll(ctx context.Context, req RawAPIRequest, opts PaginationOptions) ([]any, error) {
|
||||
limit := resolvePageLimit(opts.PageLimit)
|
||||
if opts.PageDelay <= 0 {
|
||||
opts.PageDelay = DefaultPageDelay
|
||||
}
|
||||
|
||||
var allResults []any
|
||||
pageCount := 0
|
||||
|
||||
for {
|
||||
pageCount++
|
||||
|
||||
// Safety cap — only break if a carry is active (pageCount > 1).
|
||||
if limit > 0 && pageCount > limit {
|
||||
logf(opts.LogWriter, "[pagination] ⚠ 已达安全上限 %d 页,停止翻页。数据可能不完整,请检查 API 是否异常。\n", limit)
|
||||
break
|
||||
}
|
||||
|
||||
logf(opts.LogWriter, "[pagination] 第 %d 页 请求中...\n", pageCount)
|
||||
|
||||
resp, err := c.Do(ctx, req)
|
||||
if err != nil {
|
||||
if pageCount == 1 {
|
||||
return nil, err
|
||||
}
|
||||
// Non-first page error: return what we have so far.
|
||||
return allResults, fmt.Errorf("分页第 %d 页请求失败 (已获取 %d 页结果): %w", pageCount, pageCount-1, err)
|
||||
}
|
||||
|
||||
result, hasMore, nextToken, parseErr := parsePaginatedResponse(resp)
|
||||
if parseErr != nil {
|
||||
if pageCount == 1 {
|
||||
return nil, parseErr
|
||||
}
|
||||
// Non-first page parse failure: warn the caller so users aren't
|
||||
// silently left with incomplete data.
|
||||
logf(opts.LogWriter, "[pagination] ⚠ 第 %d 页解析失败,停止翻页并返回已获取的 %d 页数据: %v\n", pageCount, pageCount-1, parseErr)
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
allResults = append(allResults, result)
|
||||
|
||||
if !hasMore || nextToken == "" {
|
||||
logf(opts.LogWriter, "[pagination] 数据获取完成 (共 %d 页)\n", pageCount)
|
||||
break
|
||||
}
|
||||
|
||||
// Inject the next page token into the request.
|
||||
req = injectPageToken(req, nextToken)
|
||||
|
||||
// Delay between pages to prevent API throttling.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return allResults, ctx.Err()
|
||||
case <-time.After(time.Duration(opts.PageDelay) * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
// resolvePageLimit translates the user-facing value into an internal limit:
|
||||
//
|
||||
// 0 → MaxPageLimit (user wants unlimited; safety cap applies)
|
||||
// positive N → min(N, MaxPageLimit) (explicit page limit, still capped)
|
||||
// negative → DefaultPageLimit (invalid input treated as default)
|
||||
func resolvePageLimit(raw int) int {
|
||||
if raw == 0 {
|
||||
return MaxPageLimit
|
||||
}
|
||||
if raw < 0 {
|
||||
return DefaultPageLimit
|
||||
}
|
||||
if raw > MaxPageLimit {
|
||||
return MaxPageLimit
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func logf(w io.Writer, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, format, args...)
|
||||
}
|
||||
|
||||
// parsePaginatedResponse extracts the response payload and pagination info.
|
||||
// It auto-detects DingTalk's two pagination patterns.
|
||||
func parsePaginatedResponse(resp *RawAPIResponse) (result any, hasMore bool, nextToken string, err error) {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !isJSONContentType(contentType) {
|
||||
return nil, false, "", fmt.Errorf("分页响应非 JSON 格式 (Content-Type: %s)", contentType)
|
||||
}
|
||||
|
||||
if len(resp.Body) == 0 {
|
||||
return nil, false, "", fmt.Errorf("分页响应体为空 (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if unmarshalErr := jsonUnmarshal(resp.Body, &payload); unmarshalErr != nil {
|
||||
return nil, false, "", fmt.Errorf("解析分页 JSON 响应失败: %w", unmarshalErr)
|
||||
}
|
||||
|
||||
// Check for DingTalk errors first.
|
||||
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
|
||||
return nil, false, "", apiErr
|
||||
}
|
||||
|
||||
// Pattern 1: cursor/next_cursor/has_more (often nested in "result" or top-level)
|
||||
if resultObj, ok := payload["result"]; ok {
|
||||
if resultMap, isMap := resultObj.(map[string]any); isMap {
|
||||
hasMore, _ = resultMap["has_more"].(bool)
|
||||
if nc, ok := resultMap["next_cursor"].(float64); ok && nc > 0 {
|
||||
nextToken = fmt.Sprintf("%.0f", nc)
|
||||
}
|
||||
return payload, hasMore, nextToken, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level has_more / next_cursor
|
||||
if hm, ok := payload["has_more"]; ok {
|
||||
hasMore, _ = hm.(bool)
|
||||
}
|
||||
if nc, ok := payload["next_cursor"]; ok {
|
||||
if ncf, isFloat := nc.(float64); isFloat && ncf > 0 {
|
||||
nextToken = fmt.Sprintf("%.0f", ncf)
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: next_token
|
||||
if nt, ok := payload["next_token"]; ok {
|
||||
if nts, isStr := nt.(string); isStr && nts != "" {
|
||||
nextToken = nts
|
||||
hasMore = true
|
||||
}
|
||||
}
|
||||
|
||||
return payload, hasMore, nextToken, nil
|
||||
}
|
||||
|
||||
// injectPageToken injects the pagination token into the next request.
|
||||
// For GET requests, it's added as a query param; for POST, it's in the body.
|
||||
func injectPageToken(req RawAPIRequest, token string) RawAPIRequest {
|
||||
method := req.Method
|
||||
if method == "GET" {
|
||||
if req.Params == nil {
|
||||
req.Params = make(map[string]any)
|
||||
}
|
||||
// Try to detect which param name the API uses
|
||||
if _, ok := req.Params["cursor"]; ok {
|
||||
req.Params["cursor"] = token
|
||||
} else if _, ok := req.Params["next_token"]; ok {
|
||||
req.Params["next_token"] = token
|
||||
} else {
|
||||
// Default to next_token for GET requests
|
||||
req.Params["next_token"] = token
|
||||
}
|
||||
} else {
|
||||
// For POST/PUT requests, inject into the body
|
||||
if bodyMap, ok := req.Data.(map[string]any); ok {
|
||||
if _, hasCursor := bodyMap["cursor"]; hasCursor {
|
||||
bodyMap["cursor"] = token
|
||||
} else {
|
||||
bodyMap["next_token"] = token
|
||||
}
|
||||
req.Data = bodyMap
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// jsonUnmarshal is a helper for JSON unmarshaling.
|
||||
func jsonUnmarshal(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseJSONMap parses a --params flag value into a map[string]any.
|
||||
// Supports:
|
||||
// - JSON string: '{"key":"value"}'
|
||||
// - "-" to read from stdin
|
||||
// - Empty string returns nil (no params)
|
||||
func ParseJSONMap(raw, flagName string, stdin io.Reader) (map[string]any, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if raw == "-" {
|
||||
data, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("从 stdin 读取 %s 失败: %w", flagName, err)
|
||||
}
|
||||
raw = strings.TrimSpace(string(data))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strip wrapping single quotes (common shell escaping).
|
||||
raw = stripSingleQuotes(raw)
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 %s JSON 失败: %w\n输入: %s", flagName, err, truncate(raw, 200))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ParseOptionalBody parses a --data flag value into a request body.
|
||||
// Returns nil for empty input. GET requests are not allowed to have a body.
|
||||
func ParseOptionalBody(method, raw string, stdin io.Reader) (any, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.ToUpper(method) == "GET" && raw != "" {
|
||||
return nil, fmt.Errorf("GET 请求不允许使用 --data 参数")
|
||||
}
|
||||
|
||||
if raw == "-" {
|
||||
data, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("从 stdin 读取 --data 失败: %w", err)
|
||||
}
|
||||
raw = strings.TrimSpace(string(data))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strip wrapping single quotes.
|
||||
raw = stripSingleQuotes(raw)
|
||||
|
||||
var result any
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 --data JSON 失败: %w\n输入: %s", err, truncate(raw, 200))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// stripSingleQuotes removes a leading and trailing single quote pair.
|
||||
func stripSingleQuotes(s string) string {
|
||||
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// truncate returns at most n characters of s, appending "..." if truncated.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJSONMap_Empty(t *testing.T) {
|
||||
result, err := ParseJSONMap("", "--params", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_ValidJSON(t *testing.T) {
|
||||
result, err := ParseJSONMap(`{"key":"value","num":42}`, "--params", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result["key"] != "value" {
|
||||
t.Errorf("expected key=value, got %v", result["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_SingleQuotes(t *testing.T) {
|
||||
result, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result["key"] != "value" {
|
||||
t.Errorf("expected key=value, got %v", result["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_Stdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"from":"stdin"}`)
|
||||
result, err := ParseJSONMap("-", "--params", stdin)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result["from"] != "stdin" {
|
||||
t.Errorf("expected from=stdin, got %v", result["from"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseJSONMap("not json", "--params", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_Empty(t *testing.T) {
|
||||
result, err := ParseOptionalBody("POST", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_GETNotAllowed(t *testing.T) {
|
||||
_, err := ParseOptionalBody("GET", `{"data":true}`, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for GET with body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_ValidPOST(t *testing.T) {
|
||||
result, err := ParseOptionalBody("POST", `{"key":"value"}`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", result)
|
||||
}
|
||||
if m["key"] != "value" {
|
||||
t.Errorf("expected key=value, got %v", m["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripSingleQuotes(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{`'hello'`, `hello`},
|
||||
{`"hello"`, `"hello"`},
|
||||
{`hello`, `hello`},
|
||||
{`''`, ``},
|
||||
{`'`, `'`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripSingleQuotes(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripSingleQuotes(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("hello", 10); got != "hello" {
|
||||
t.Errorf("expected hello, got %q", got)
|
||||
}
|
||||
if got := truncate("hello world", 5); got != "hello..." {
|
||||
t.Errorf("expected hello..., got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
|
||||
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
|
||||
// a custom Transport without an explicit Proxy field silently bypasses
|
||||
// HTTP_PROXY/HTTPS_PROXY.
|
||||
//
|
||||
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
|
||||
// it, because http.ProxyFromEnvironment memoises the env on first call;
|
||||
// other tests that read proxy env early would make a value-based assertion
|
||||
// flaky.
|
||||
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tr := defaultTransport()
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// ResponseOptions controls how an API response is processed.
|
||||
type ResponseOptions struct {
|
||||
OutputPath string // --output file path for binary responses
|
||||
Format output.Format // output format (json|table|raw)
|
||||
JqExpr string // --jq expression
|
||||
Fields string // --fields comma-separated field names
|
||||
Out io.Writer // stdout
|
||||
ErrOut io.Writer // stderr
|
||||
}
|
||||
|
||||
// HandleResponse routes response processing based on Content-Type and status code.
|
||||
func HandleResponse(resp *RawAPIResponse, opts ResponseOptions) error {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
isJSON := isJSONContentType(contentType)
|
||||
|
||||
// HTTP error with non-JSON body: print as plain text error.
|
||||
if resp.StatusCode >= 400 && !isJSON {
|
||||
return fmt.Errorf("API 请求失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(resp.Body)))
|
||||
}
|
||||
|
||||
// JSON response
|
||||
if isJSON {
|
||||
return handleJSONResponse(resp, opts)
|
||||
}
|
||||
|
||||
// Binary response
|
||||
return handleBinaryResponse(resp, opts)
|
||||
}
|
||||
|
||||
// handleJSONResponse parses the JSON body, checks for DingTalk business errors,
|
||||
// and writes the output using the configured format and filters.
|
||||
func handleJSONResponse(resp *RawAPIResponse, opts ResponseOptions) error {
|
||||
if len(resp.Body) == 0 {
|
||||
return fmt.Errorf("API 返回空响应体 (HTTP %d),如需下载文件请使用 --output 参数", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal(resp.Body, &payload); err != nil {
|
||||
return fmt.Errorf("解析 JSON 响应失败: %w", err)
|
||||
}
|
||||
|
||||
// Check for DingTalk business error: {"errcode": xxx, "errmsg": "xxx"}
|
||||
if apiErr := checkDingTalkError(payload, resp.StatusCode); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
return output.WriteFiltered(opts.Out, opts.Format, payload, opts.Fields, opts.JqExpr)
|
||||
}
|
||||
|
||||
// checkDingTalkError inspects a parsed JSON response for DingTalk error codes.
|
||||
// Returns nil if no error is detected.
|
||||
func checkDingTalkError(payload any, statusCode int) error {
|
||||
obj, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for errcode != 0
|
||||
if errcode, hasCode := obj["errcode"]; hasCode {
|
||||
code := toFloat64(errcode)
|
||||
if code != 0 {
|
||||
errmsg, _ := obj["errmsg"].(string)
|
||||
if errmsg == "" {
|
||||
errmsg = "unknown error"
|
||||
}
|
||||
return fmt.Errorf("API 业务错误 (errcode: %.0f, HTTP %d): %s", code, statusCode, errmsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Also check HTTP error status even if no errcode field
|
||||
if statusCode >= 400 {
|
||||
errmsg, _ := obj["errmsg"].(string)
|
||||
if errmsg == "" {
|
||||
errmsg, _ = obj["message"].(string)
|
||||
}
|
||||
if errmsg == "" {
|
||||
errmsg, _ = obj["error"].(string)
|
||||
}
|
||||
if errmsg != "" {
|
||||
return fmt.Errorf("API 请求失败 (HTTP %d): %s", statusCode, errmsg)
|
||||
}
|
||||
return fmt.Errorf("API 请求失败 (HTTP %d)", statusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleBinaryResponse saves the response body to a file.
|
||||
func handleBinaryResponse(resp *RawAPIResponse, opts ResponseOptions) error {
|
||||
outputPath := strings.TrimSpace(opts.OutputPath)
|
||||
|
||||
if outputPath == "" {
|
||||
// Try to infer filename from Content-Disposition header.
|
||||
outputPath = inferFilename(resp.Header)
|
||||
if outputPath == "" {
|
||||
return fmt.Errorf("响应为非 JSON 格式 (Content-Type: %s),请使用 --output 指定保存路径",
|
||||
resp.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
dir := filepath.Dir(outputPath)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建输出目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, resp.Body, 0o644); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.ErrOut, "已保存到: %s (%d 字节)\n", outputPath, len(resp.Body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// inferFilename tries to extract a filename from the Content-Disposition header.
|
||||
func inferFilename(header http.Header) string {
|
||||
cd := header.Get("Content-Disposition")
|
||||
if cd == "" {
|
||||
return ""
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(cd)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(params["filename"])
|
||||
}
|
||||
|
||||
// isJSONContentType returns true if the Content-Type indicates JSON.
|
||||
func isJSONContentType(ct string) bool {
|
||||
ct = strings.TrimSpace(strings.ToLower(ct))
|
||||
return strings.HasPrefix(ct, "application/json") ||
|
||||
strings.HasPrefix(ct, "text/json") ||
|
||||
strings.Contains(ct, "+json")
|
||||
}
|
||||
|
||||
// toFloat64 attempts to convert a JSON number to float64.
|
||||
func toFloat64(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case json.Number:
|
||||
f, _ := n.Float64()
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AllowedHosts is the set of trusted DingTalk API hosts.
|
||||
// Only these hosts may receive access tokens to prevent token leakage.
|
||||
var AllowedHosts = map[string]bool{
|
||||
"api.dingtalk.com": true,
|
||||
"oapi.dingtalk.com": true,
|
||||
}
|
||||
|
||||
// ValidateTargetHost checks that the resolved request URL targets a trusted
|
||||
// DingTalk host. This prevents access-token leakage to arbitrary domains.
|
||||
func ValidateTargetHost(fullURL string) error {
|
||||
parsed, err := url.Parse(fullURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析请求 URL: %w", err)
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if !AllowedHosts[host] {
|
||||
return fmt.Errorf(
|
||||
"安全限制: 目标域名 %q 不在允许列表中。\n"+
|
||||
"dws api 仅允许向以下域名发起请求:\n"+
|
||||
" - api.dingtalk.com (新版 API)\n"+
|
||||
" - oapi.dingtalk.com (旧版 API)\n"+
|
||||
"请检查 URL 或 --base-url 参数是否正确。",
|
||||
host,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateMethod checks that the HTTP method is one of the five allowed methods.
|
||||
func ValidateMethod(method string) (string, error) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(method))
|
||||
if !AllowedMethods[upper] {
|
||||
return "", fmt.Errorf("不支持的 HTTP 方法: %s (允许: GET, POST, PUT, PATCH, DELETE)", method)
|
||||
}
|
||||
return upper, nil
|
||||
}
|
||||
|
||||
// ValidatePath checks the API path for injection attacks and dangerous characters.
|
||||
func ValidatePath(path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("API 路径不能为空")
|
||||
}
|
||||
if err := rejectDangerousChars(path, "path"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject path traversal
|
||||
if strings.Contains(path, "..") {
|
||||
return fmt.Errorf("API 路径不能包含 '..' (路径遍历)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUserInput checks a user-provided string for control characters and
|
||||
// dangerous Unicode codepoints that could enable injection attacks.
|
||||
func ValidateUserInput(value, fieldName string) error {
|
||||
return rejectDangerousChars(value, fieldName)
|
||||
}
|
||||
|
||||
// rejectDangerousChars rejects C0 control characters (except \t and \n),
|
||||
// DEL (0x7F), and dangerous Unicode codepoints in a string.
|
||||
func rejectDangerousChars(s, fieldName string) error {
|
||||
for i, r := range s {
|
||||
// Allow tab and newline
|
||||
if r == '\t' || r == '\n' {
|
||||
continue
|
||||
}
|
||||
// Reject C0 control chars (0x00-0x1F) and DEL (0x7F)
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return fmt.Errorf("%s 包含非法控制字符 (位置 %d, U+%04X)", fieldName, i, r)
|
||||
}
|
||||
// Reject dangerous Unicode
|
||||
if isDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s 包含危险 Unicode 字符 (位置 %d, U+%04X)", fieldName, i, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDangerousUnicode returns true for Unicode codepoints that can be used
|
||||
// for visual spoofing or terminal injection attacks.
|
||||
func isDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
// Zero-width characters
|
||||
case r >= 0x200B && r <= 0x200D:
|
||||
return true
|
||||
// BOM
|
||||
case r == 0xFEFF:
|
||||
return true
|
||||
// Bidi override characters
|
||||
case r >= 0x202A && r <= 0x202E:
|
||||
return true
|
||||
// Line/paragraph separator
|
||||
case r == 0x2028 || r == 0x2029:
|
||||
return true
|
||||
// Bidi isolate characters
|
||||
case r >= 0x2066 && r <= 0x2069:
|
||||
return true
|
||||
// Additional Bidi controls
|
||||
case r == 0x061C:
|
||||
return true
|
||||
// Non-characters
|
||||
case r >= 0xFDD0 && r <= 0xFDEF:
|
||||
return true
|
||||
}
|
||||
// Object replacement (U+FFFC) / replacement (U+FFFD) characters and
|
||||
// other non-printable non-ASCII runes (e.g. CJK, symbols) are allowed
|
||||
// through — only the explicit dangerous ranges above are blocked.
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateStdinExclusion checks that --params and --data don't both read from stdin.
|
||||
func ValidateStdinExclusion(params, data string) error {
|
||||
if strings.TrimSpace(params) == "-" && strings.TrimSpace(data) == "-" {
|
||||
return fmt.Errorf("--params 和 --data 不能同时从 stdin 读取 (-)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFlagExclusion checks mutual exclusion between flags.
|
||||
func ValidateFlagExclusion(outputPath string, pageAll bool) error {
|
||||
if strings.TrimSpace(outputPath) != "" && pageAll {
|
||||
return fmt.Errorf("--output 和 --page-all 不能同时使用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMethod(t *testing.T) {
|
||||
valid := []string{"GET", "get", "Post", "put", "PATCH", "delete"}
|
||||
for _, m := range valid {
|
||||
got, err := ValidateMethod(m)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateMethod(%q) unexpected error: %v", m, err)
|
||||
}
|
||||
if got != strings.ToUpper(m) {
|
||||
t.Errorf("ValidateMethod(%q) = %q, want %q", m, got, strings.ToUpper(m))
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{"HEAD", "OPTIONS", "TRACE", "CONNECT", "INVALID", ""}
|
||||
for _, m := range invalid {
|
||||
_, err := ValidateMethod(m)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateMethod(%q) expected error, got nil", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePath(t *testing.T) {
|
||||
// Valid paths
|
||||
for _, p := range []string{"/v1.0/users", "/v2.0/calendar/events", "v1.0/contact/users/me"} {
|
||||
if err := ValidatePath(p); err != nil {
|
||||
t.Errorf("ValidatePath(%q) unexpected error: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty path
|
||||
if err := ValidatePath(""); err == nil {
|
||||
t.Error("ValidatePath(\"\") expected error")
|
||||
}
|
||||
|
||||
// Path traversal
|
||||
if err := ValidatePath("/v1.0/../secret"); err == nil {
|
||||
t.Error("ValidatePath with .. expected error")
|
||||
}
|
||||
|
||||
// Control character
|
||||
if err := ValidatePath("/v1.0/\x00test"); err == nil {
|
||||
t.Error("ValidatePath with null byte expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectDangerousUnicode(t *testing.T) {
|
||||
// Zero-width space
|
||||
if err := ValidateUserInput("hello\u200Bworld", "test"); err == nil {
|
||||
t.Error("expected error for zero-width space")
|
||||
}
|
||||
// BOM
|
||||
if err := ValidateUserInput("\uFEFFhello", "test"); err == nil {
|
||||
t.Error("expected error for BOM")
|
||||
}
|
||||
// Bidi override
|
||||
if err := ValidateUserInput("hello\u202Aworld", "test"); err == nil {
|
||||
t.Error("expected error for bidi override")
|
||||
}
|
||||
// Normal string should pass
|
||||
if err := ValidateUserInput("hello world 你好", "test"); err != nil {
|
||||
t.Errorf("unexpected error for normal string: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStdinExclusion(t *testing.T) {
|
||||
if err := ValidateStdinExclusion("-", "-"); err == nil {
|
||||
t.Error("expected error when both params and data read from stdin")
|
||||
}
|
||||
if err := ValidateStdinExclusion("-", "{}"); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if err := ValidateStdinExclusion("{}", "-"); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlagExclusion(t *testing.T) {
|
||||
if err := ValidateFlagExclusion("output.json", true); err == nil {
|
||||
t.Error("expected error when --output and --page-all both set")
|
||||
}
|
||||
if err := ValidateFlagExclusion("output.json", false); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if err := ValidateFlagExclusion("", true); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "****"},
|
||||
{"abc", "****"},
|
||||
{"abcd", "****"},
|
||||
{"abcde", "abcd****"},
|
||||
{"abcdefghij", "abcd****"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := MaskToken(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("MaskToken(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTargetHost(t *testing.T) {
|
||||
// Allowed hosts
|
||||
allowed := []string{
|
||||
"https://api.dingtalk.com/v1.0/contact/users/me",
|
||||
"https://oapi.dingtalk.com/topapi/v2/user/get",
|
||||
"https://API.DINGTALK.COM/v1.0/test",
|
||||
"https://OAPI.DINGTALK.COM/topapi/test",
|
||||
}
|
||||
for _, u := range allowed {
|
||||
if err := ValidateTargetHost(u); err != nil {
|
||||
t.Errorf("ValidateTargetHost(%q) unexpected error: %v", u, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked hosts
|
||||
blocked := []string{
|
||||
"https://oapi.dingtalk.fakedomain.com/topapi/v2/user/get",
|
||||
"https://fake.com/v1.0/test",
|
||||
"https://api.dingtalk.com.evil.com/v1.0/test",
|
||||
"https://evil.com/redirect?url=https://api.dingtalk.com",
|
||||
"http://localhost:8080/v1.0/test",
|
||||
"https://dingtalk.com/v1.0/test",
|
||||
}
|
||||
for _, u := range blocked {
|
||||
if err := ValidateTargetHost(u); err == nil {
|
||||
t.Errorf("ValidateTargetHost(%q) expected error, got nil", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
|
||||
// the same host compatibility hooks as MCP. It mirrors the former body of
|
||||
// getCachedRuntimeToken (excluding process-level cache and timing).
|
||||
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
token, tokenErr := provider.GetAccessToken(ctx)
|
||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
return "", tokenErr
|
||||
}
|
||||
manager := authpkg.NewManager(configDir, nil)
|
||||
configureLegacyAuthManagerCompatibility(manager)
|
||||
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
|
||||
return strings.TrimSpace(leg), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should
|
||||
// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches
|
||||
// the active edition config directory, the same process-cached path as MCP is used.
|
||||
// Otherwise tokens are loaded from configDir with host compatibility hooks applied.
|
||||
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
|
||||
if t := strings.TrimSpace(explicitToken); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) {
|
||||
if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" {
|
||||
return tok, nil
|
||||
}
|
||||
return "", noCredentialsError()
|
||||
}
|
||||
tok, err := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tok != "" {
|
||||
return tok, nil
|
||||
}
|
||||
return "", noCredentialsError()
|
||||
}
|
||||
|
||||
func noCredentialsError() error {
|
||||
if edition.Get().IsEmbedded {
|
||||
return fmt.Errorf("认证信息已失效,请重新认证")
|
||||
}
|
||||
return fmt.Errorf("no credentials found, run: dws auth login")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAuxiliaryAccessToken_explicitToken(t *testing.T) {
|
||||
tok, err := ResolveAuxiliaryAccessToken(context.Background(), "/any/dir", " bearer-xyz ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != "bearer-xyz" {
|
||||
t.Fatalf("got %q, want bearer-xyz", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuxiliaryAccessToken_emptyConfigDir(t *testing.T) {
|
||||
_, err := ResolveAuxiliaryAccessToken(context.Background(), " ", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty config directory")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
|
||||
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/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// apiFlags holds the flags specific to the `dws api` command.
|
||||
type apiFlags struct {
|
||||
params string
|
||||
data string
|
||||
pageAll bool
|
||||
pageLimit int
|
||||
pageDelay int
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls.
|
||||
func newAPICommand(flags *GlobalFlags) *cobra.Command {
|
||||
af := &apiFlags{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <METHOD> <PATH> [flags]",
|
||||
Short: "调用钉钉 OpenAPI (Raw HTTP)",
|
||||
Long: `直接调用钉钉 OpenAPI,支持 api.dingtalk.com 和 oapi.dingtalk.com 两个域名。
|
||||
|
||||
api.dingtalk.com:
|
||||
Token 通过 HTTP Header (x-acs-dingtalk-access-token) 传递。
|
||||
路径格式: /v1.0/xxx 或 /v2.0/xxx
|
||||
|
||||
oapi.dingtalk.com:
|
||||
Token 通过 URL 查询参数 (access_token) 传递。
|
||||
路径格式: /topapi/v2/xxx 或完整 URL https://oapi.dingtalk.com/topapi/...
|
||||
|
||||
仅限使用自有应用凭证(--client-id/--client-secret)登录后使用。
|
||||
通过 MCP 默认凭证登录获取的加密 token 不支持 raw API 调用。
|
||||
|
||||
示例:
|
||||
# === api.dingtalk.com ===
|
||||
|
||||
# 获取当前用户信息
|
||||
dws api GET /v1.0/contact/users/me
|
||||
|
||||
# 搜索用户 (POST + JSON body)
|
||||
dws api POST /v1.0/contact/users/search \
|
||||
--data '{"queryWord":"张三","offset":0,"size":10}'
|
||||
|
||||
# 创建日历事件
|
||||
dws api POST /v1.0/calendar/users/me/calendars/primary/events \
|
||||
--data '{"summary":"Team Meeting","start":{"dateTime":"2026-01-01T10:00:00+08:00"}}'
|
||||
|
||||
# === oapi.dingtalk.com ===
|
||||
|
||||
# 获取用户详情 (使用 --base-url)
|
||||
dws api POST /topapi/v2/user/get \
|
||||
--base-url https://oapi.dingtalk.com \
|
||||
--data '{"userid":"manager123"}'
|
||||
|
||||
# 也可以直接使用完整 URL
|
||||
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
|
||||
--data '{"userid":"manager123"}'
|
||||
|
||||
# === 通用功能 ===
|
||||
|
||||
# 分页获取所有结果
|
||||
dws api GET /v1.0/attendance/groups --page-all --page-limit 5
|
||||
|
||||
# Dry-run 预览请求
|
||||
dws api GET /v1.0/contact/users/me --dry-run
|
||||
|
||||
# 使用 jq 过滤输出
|
||||
dws api GET /v1.0/contact/users/me --jq '.nick'`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runAPI(cmd, args, flags, af)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&af.params, "params", "", "查询参数 JSON (支持 - 从 stdin 读取)")
|
||||
cmd.Flags().StringVar(&af.data, "data", "", "请求体 JSON (支持 - 从 stdin 读取)")
|
||||
cmd.Flags().BoolVar(&af.pageAll, "page-all", false, "自动遍历所有分页")
|
||||
cmd.Flags().IntVar(&af.pageLimit, "page-limit", apiclient.DefaultPageLimit, "最大翻页数 (0=不限, 默认10, 硬上限500)")
|
||||
cmd.Flags().IntVar(&af.pageDelay, "page-delay", apiclient.DefaultPageDelay, "分页间隔毫秒")
|
||||
cmd.Flags().StringVar(&af.baseURL, "base-url", "", "覆盖 API 基础 URL (默认 https://api.dingtalk.com)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runAPI is the main execution logic for `dws api`.
|
||||
func runAPI(cmd *cobra.Command, args []string, gf *GlobalFlags, af *apiFlags) error {
|
||||
ctx := cmd.Context()
|
||||
method := args[0]
|
||||
path := args[1]
|
||||
|
||||
// 0. Reject path with inline query string — must use --params instead.
|
||||
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||
cleanPath := path[:idx]
|
||||
// Parse query string to generate the exact --params JSON for the user.
|
||||
paramsJSON := parseQueryStringToJSON(path[idx+1:])
|
||||
return apperrors.NewValidation(
|
||||
"API 路径中不允许直接拼接查询参数(?key=value),该写法会导致参数在解析时被静默丢弃。\n\n"+
|
||||
"命令格式可参考:\n\n"+
|
||||
" dws api "+method+" "+cleanPath+" --params '"+paramsJSON+"'",
|
||||
apperrors.WithHint("查询参数必须通过 --params 传递,形如 --params '{\"key\":\"value\"}'"),
|
||||
)
|
||||
}
|
||||
|
||||
// 1. Validate HTTP method.
|
||||
method, err := apiclient.ValidateMethod(method)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
|
||||
// 2. Validate API path.
|
||||
if err := apiclient.ValidatePath(path); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
|
||||
// 3. Validate input safety for params and data.
|
||||
if err := apiclient.ValidateUserInput(af.params, "--params"); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if err := apiclient.ValidateUserInput(af.data, "--data"); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
|
||||
// 4. Validate mutual exclusion.
|
||||
if err := apiclient.ValidateStdinExclusion(af.params, af.data); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if err := apiclient.ValidateFlagExclusion(gf.Output, af.pageAll); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
|
||||
// 5. Parse --params.
|
||||
params, err := apiclient.ParseJSONMap(af.params, "--params", os.Stdin)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
|
||||
// 6. Parse --data.
|
||||
body, err := apiclient.ParseOptionalBody(method, af.data, os.Stdin)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
|
||||
// 7. Normalise and validate target URL.
|
||||
fullURL := apiclient.NormalisePath(path, af.baseURL)
|
||||
|
||||
// 7b. Security: validate target host is a trusted DingTalk domain.
|
||||
if err := apiclient.ValidateTargetHost(fullURL); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
|
||||
// 8. Resolve app-level token (with timeout).
|
||||
tokenCtx, tokenCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer tokenCancel()
|
||||
token, err := resolveRawAPIToken(tokenCtx, gf.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 9. Build request.
|
||||
req := apiclient.RawAPIRequest{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Params: params,
|
||||
Data: body,
|
||||
}
|
||||
|
||||
baseURL := af.baseURL
|
||||
|
||||
// 10. Dry-run mode.
|
||||
if gf.DryRun {
|
||||
return apiclient.PrintDryRun(cmd.OutOrStdout(), req, baseURL, token)
|
||||
}
|
||||
|
||||
// 11. Create client with timeout.
|
||||
client := apiclient.NewClient(token, baseURL)
|
||||
if gf.Timeout > 0 {
|
||||
client.HTTPClient.Timeout = time.Duration(gf.Timeout) * time.Second
|
||||
}
|
||||
|
||||
// 12. Execute request (with or without pagination).
|
||||
format := output.Format(gf.Format)
|
||||
respOpts := apiclient.ResponseOptions{
|
||||
OutputPath: gf.Output,
|
||||
Format: format,
|
||||
JqExpr: gf.JQ,
|
||||
Fields: gf.Fields,
|
||||
Out: cmd.OutOrStdout(),
|
||||
ErrOut: cmd.ErrOrStderr(),
|
||||
}
|
||||
|
||||
if af.pageAll {
|
||||
return runPaginated(ctx, client, req, af, respOpts)
|
||||
}
|
||||
|
||||
resp, err := client.Do(ctx, req)
|
||||
if err != nil {
|
||||
return apperrors.NewAPI(fmt.Sprintf("API 请求失败: %v", err))
|
||||
}
|
||||
return apiclient.HandleResponse(resp, respOpts)
|
||||
}
|
||||
|
||||
// runPaginated executes a paginated API request and outputs all results.
|
||||
func runPaginated(ctx context.Context, client *apiclient.APIClient, req apiclient.RawAPIRequest, af *apiFlags, opts apiclient.ResponseOptions) error {
|
||||
pages, err := client.PaginateAll(ctx, req, apiclient.PaginationOptions{
|
||||
PageLimit: af.pageLimit,
|
||||
PageDelay: af.pageDelay,
|
||||
LogWriter: opts.ErrOut,
|
||||
})
|
||||
if err != nil && len(pages) == 0 {
|
||||
return apperrors.NewAPI(fmt.Sprintf("分页请求失败: %v", err))
|
||||
}
|
||||
|
||||
// Output all pages as a JSON array.
|
||||
return output.WriteFiltered(opts.Out, opts.Format, pages, opts.Fields, opts.JqExpr)
|
||||
}
|
||||
|
||||
// parseQueryStringToJSON parses a raw URL query string into a JSON object string.
|
||||
// Uses simple & and = splitting (no URL decoding) to preserve values as-is.
|
||||
func parseQueryStringToJSON(rawQuery string) string {
|
||||
rawQuery = strings.TrimSpace(rawQuery)
|
||||
if rawQuery == "" {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
paramsMap := make(map[string]any)
|
||||
for _, pair := range strings.Split(rawQuery, "&") {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
key := strings.TrimSpace(kv[0])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
var val string
|
||||
if len(kv) == 2 {
|
||||
val = strings.TrimSpace(kv[1])
|
||||
}
|
||||
if val == "" {
|
||||
continue // skip empty values like nextToken=
|
||||
}
|
||||
paramsMap[key] = val
|
||||
}
|
||||
|
||||
if len(paramsMap) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
data, err := json.Marshal(paramsMap)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// resolveRawAPIToken resolves an app-level access token for raw API calls.
|
||||
// It uses AppTokenProvider to fetch from the unified POST /v1.0/oauth2/accessToken
|
||||
// endpoint. The same token works for both api.dingtalk.com and oapi.dingtalk.com.
|
||||
// Tokens are cached in keychain and auto-refreshed when expired.
|
||||
func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
// Explicit --token flag takes priority (user knows what they're doing).
|
||||
if t := strings.TrimSpace(explicitToken); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Resolve app credentials (clientID/clientSecret).
|
||||
appKey := authpkg.ClientID()
|
||||
appSecret := authpkg.ClientSecret()
|
||||
|
||||
if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") {
|
||||
return "", apperrors.NewAuth(
|
||||
"缺少应用凭证。dws api 需要使用自有应用的 AppKey/AppSecret 获取 accessToken。\n\n" +
|
||||
"解决方法:\n" +
|
||||
" 1. 使用自有应用凭证登录:\n" +
|
||||
" dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>\n\n" +
|
||||
" 2. 或通过环境变量设置:\n" +
|
||||
" export DWS_CLIENT_ID=<APP_KEY>\n" +
|
||||
" export DWS_CLIENT_SECRET=<APP_SECRET>\n" +
|
||||
" dws auth login\n\n" +
|
||||
"说明: 通过 MCP 默认凭证登录的加密 token 无法用于 raw API 调用。",
|
||||
)
|
||||
}
|
||||
|
||||
// Use AppTokenProvider for automatic caching and refresh.
|
||||
configDir := defaultConfigDir()
|
||||
provider := &authpkg.AppTokenProvider{
|
||||
ConfigDir: configDir,
|
||||
AppKey: appKey,
|
||||
AppSecret: appSecret,
|
||||
}
|
||||
token, err := provider.GetToken(ctx)
|
||||
if err != nil {
|
||||
return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err))
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return "", apperrors.NewAuth("应用级访问令牌为空,请检查应用凭证是否正确")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseQueryStringToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{
|
||||
name: "simple key-value",
|
||||
raw: "timeMin=2026-04-01&maxResults=10",
|
||||
want: `{"maxResults":"10","timeMin":"2026-04-01"}`,
|
||||
},
|
||||
{
|
||||
name: "with special chars",
|
||||
raw: "timeMin=2026-04-01T14:00:00+08:00&showDeleted=false",
|
||||
want: `{"showDeleted":"false","timeMin":"2026-04-01T14:00:00+08:00"}`,
|
||||
},
|
||||
{
|
||||
name: "empty value skipped",
|
||||
raw: "nextToken=&syncToken=abc",
|
||||
want: `{"syncToken":"abc"}`,
|
||||
},
|
||||
{
|
||||
name: "all empty",
|
||||
raw: "nextToken=&syncToken=",
|
||||
want: "{}",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
raw: "",
|
||||
want: "{}",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseQueryStringToJSON(tt.raw)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseQueryStringToJSON(%q) = %s, want %s", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPI_QueryStringBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gf := &GlobalFlags{}
|
||||
cmd := newAPICommand(gf)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
|
||||
cmd.SetArgs([]string{"GET", "/v1.0/calendar/users/me/events?timeMin=2026-04-01&maxResults=10"})
|
||||
err := cmd.Execute()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when path contains query string, got nil")
|
||||
}
|
||||
errMsg := stderr.String()
|
||||
if !strings.Contains(errMsg, "--params") {
|
||||
t.Errorf("expected --params hint in error, got: %s", errMsg)
|
||||
}
|
||||
if !strings.Contains(errMsg, "maxResults") {
|
||||
t.Errorf("expected parsed query params in error, got: %s", errMsg)
|
||||
}
|
||||
if !strings.Contains(errMsg, "/v1.0/calendar/users/me/events") {
|
||||
t.Errorf("expected clean path in suggestion, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPI_NoErrorWithoutQueryString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gf := &GlobalFlags{}
|
||||
cmd := newAPICommand(gf)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
|
||||
cmd.SetArgs([]string{"GET", "/v1.0/contact/users/me"})
|
||||
err := cmd.Execute()
|
||||
|
||||
errMsg := stderr.String()
|
||||
if strings.Contains(errMsg, "查询参数") {
|
||||
t.Errorf("should not reject path without query string, got: %s", errMsg)
|
||||
}
|
||||
_ = err
|
||||
}
|
||||
@@ -24,8 +24,9 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -48,7 +49,9 @@ func buildAuthCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
if !edition.Get().HideAuthLogin {
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
@@ -65,16 +68,21 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
Long: `登录钉钉并获取认证凭证。
|
||||
|
||||
支持的登录方式:
|
||||
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
|
||||
- 直接提供 Token: 通过 --token 参数传入已有 token
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 扫码登录
|
||||
dws auth login # 本机扫码登录 (loopback 流)
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -118,6 +126,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -185,15 +194,36 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
defer cancel()
|
||||
_ = authpkg.RevokeTokenRemote(revokeCtx)
|
||||
|
||||
// Load token data to get associated clientId before deletion
|
||||
var storedClientID string
|
||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||||
storedClientID = tokenData.ClientID
|
||||
}
|
||||
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
// Clean up associated client secret and app token from keychain
|
||||
if storedClientID != "" {
|
||||
_ = authpkg.DeleteClientSecret(storedClientID)
|
||||
_ = authpkg.DeleteAppTokenData(storedClientID)
|
||||
}
|
||||
// Also try cleaning app token using appKey from app config
|
||||
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
|
||||
_ = authpkg.DeleteAppTokenData(appKey)
|
||||
}
|
||||
// Clean up app credentials (app.json + keychain secret)
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token.json"))
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -223,6 +253,8 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
tokenData = updatedData
|
||||
refreshed = true
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
_ = authpkg.DeleteTokenData(configDir)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
@@ -250,7 +282,9 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -286,6 +320,7 @@ func newAuthExchangeCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -325,10 +360,13 @@ func newAuthResetCommand() *cobra.Command {
|
||||
}
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import (
|
||||
)
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
// Cleanup keychain after test
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
@@ -44,7 +46,7 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
CorpID: "dingcorp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
}
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// authRetryingKey marks a context that has already attempted one
|
||||
// AuthRefreshRequired-driven retry of the current invocation. The runner uses
|
||||
// this to refuse a second refresh+retry pass and surface the original cause
|
||||
// to the user instead.
|
||||
type authRetryingKeyType struct{}
|
||||
|
||||
var authRetryingKey = authRetryingKeyType{}
|
||||
|
||||
// IsAuthRetrying reports whether the current context is already inside an
|
||||
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
|
||||
func IsAuthRetrying(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := ctx.Value(authRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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 "sync"
|
||||
|
||||
// PluginAuth holds authentication credentials for a plugin-owned
|
||||
// streamable-http MCP server. Each server is keyed by its canonical
|
||||
// product ID (CLI.ID) so that different servers can use independent
|
||||
// tokens without interfering with each other or with the default
|
||||
// DingTalk OAuth token.
|
||||
type PluginAuth struct {
|
||||
// Token is the Bearer token extracted from the plugin's
|
||||
// "Authorization" header (e.g. a third-party API key).
|
||||
Token string
|
||||
|
||||
// ExtraHeaders contains any additional custom HTTP headers
|
||||
// declared by the plugin (excluding Authorization).
|
||||
ExtraHeaders map[string]string
|
||||
|
||||
// TrustedDomains lists the hostnames that the token is allowed
|
||||
// to be sent to. Typically derived from the server endpoint.
|
||||
TrustedDomains []string
|
||||
}
|
||||
|
||||
var (
|
||||
pluginAuthMu sync.RWMutex
|
||||
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||
)
|
||||
|
||||
// RegisterPluginAuth stores authentication credentials for a plugin
|
||||
// server keyed by its canonical product ID. The runner looks up these
|
||||
// credentials at execution time to inject the correct Bearer token
|
||||
// instead of the default DingTalk OAuth token.
|
||||
func RegisterPluginAuth(productID string, auth *PluginAuth) {
|
||||
pluginAuthMu.Lock()
|
||||
defer pluginAuthMu.Unlock()
|
||||
pluginAuthRegistry[productID] = auth
|
||||
}
|
||||
|
||||
// LookupPluginAuth returns the authentication credentials registered
|
||||
// for the given product ID, or nil if none exists.
|
||||
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
|
||||
pluginAuthMu.RLock()
|
||||
defer pluginAuthMu.RUnlock()
|
||||
auth, ok := pluginAuthRegistry[productID]
|
||||
return auth, ok
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestPluginAuthRegistry(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "test-product")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
// Initially not found
|
||||
if _, ok := LookupPluginAuth("test-product"); ok {
|
||||
t.Error("expected LookupPluginAuth to return false for unregistered product")
|
||||
}
|
||||
|
||||
// Register auth credentials
|
||||
auth := &PluginAuth{
|
||||
Token: "sk-test-token-12345",
|
||||
ExtraHeaders: map[string]string{"X-Custom": "value"},
|
||||
TrustedDomains: []string{"api.example.com", "*.example.com"},
|
||||
}
|
||||
RegisterPluginAuth("test-product", auth)
|
||||
|
||||
// Now should be found
|
||||
got, ok := LookupPluginAuth("test-product")
|
||||
if !ok {
|
||||
t.Fatal("expected LookupPluginAuth to return true after registration")
|
||||
}
|
||||
if got != auth {
|
||||
t.Error("LookupPluginAuth returned different auth instance")
|
||||
}
|
||||
if got.Token != "sk-test-token-12345" {
|
||||
t.Errorf("Token = %q, want sk-test-token-12345", got.Token)
|
||||
}
|
||||
if got.ExtraHeaders["X-Custom"] != "value" {
|
||||
t.Errorf("ExtraHeaders[X-Custom] = %q, want value", got.ExtraHeaders["X-Custom"])
|
||||
}
|
||||
if len(got.TrustedDomains) != 2 {
|
||||
t.Errorf("TrustedDomains len = %d, want 2", len(got.TrustedDomains))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAuthRegistryIsolation(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "product-a")
|
||||
delete(pluginAuthRegistry, "product-b")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
authA := &PluginAuth{Token: "token-a"}
|
||||
authB := &PluginAuth{Token: "token-b"}
|
||||
|
||||
RegisterPluginAuth("product-a", authA)
|
||||
RegisterPluginAuth("product-b", authB)
|
||||
|
||||
gotA, okA := LookupPluginAuth("product-a")
|
||||
gotB, okB := LookupPluginAuth("product-b")
|
||||
|
||||
if !okA || !okB {
|
||||
t.Fatal("expected both products to be registered")
|
||||
}
|
||||
if gotA.Token != "token-a" {
|
||||
t.Errorf("product-a Token = %q, want token-a", gotA.Token)
|
||||
}
|
||||
if gotB.Token != "token-b" {
|
||||
t.Errorf("product-b Token = %q, want token-b", gotB.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveToolCLIName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"web_search", "web-search"},
|
||||
{"maps.search_poi", "search-poi"},
|
||||
{"maps.geo", "geo"},
|
||||
{"simple", "simple"},
|
||||
{"a.b.deep_nested_name", "deep-nested-name"},
|
||||
{"already-kebab", "already-kebab"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := deriveToolCLIName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("deriveToolCLIName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPluginAuthFromHeaders(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
pluginAuthMu.Lock()
|
||||
delete(pluginAuthRegistry, "test-srv")
|
||||
pluginAuthMu.Unlock()
|
||||
}()
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Key: "test-srv",
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
CLI: market.CLIOverlay{ID: "test-srv", Command: "test-srv"},
|
||||
AuthHeaders: map[string]string{
|
||||
"Authorization": "Bearer sk-my-secret-key",
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
|
||||
auth, ok := LookupPluginAuth("test-srv")
|
||||
if !ok {
|
||||
t.Fatal("expected auth to be registered after registerPluginAuthFromHeaders")
|
||||
}
|
||||
if auth.Token != "sk-my-secret-key" {
|
||||
t.Errorf("Token = %q, want sk-my-secret-key", auth.Token)
|
||||
}
|
||||
if auth.ExtraHeaders["X-Custom"] != "custom-value" {
|
||||
t.Errorf("ExtraHeaders[X-Custom] = %q, want custom-value", auth.ExtraHeaders["X-Custom"])
|
||||
}
|
||||
if len(auth.TrustedDomains) != 2 {
|
||||
t.Fatalf("TrustedDomains len = %d, want 2", len(auth.TrustedDomains))
|
||||
}
|
||||
if auth.TrustedDomains[0] != "api.example.com" {
|
||||
t.Errorf("TrustedDomains[0] = %q, want api.example.com", auth.TrustedDomains[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPluginAuthFromHeadersNoAuth(t *testing.T) {
|
||||
srv := market.ServerDescriptor{
|
||||
Key: "no-auth-srv",
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
CLI: market.CLIOverlay{ID: "no-auth-srv"},
|
||||
AuthHeaders: map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
|
||||
// Should not register because there's no Authorization header
|
||||
if _, ok := LookupPluginAuth("no-auth-srv"); ok {
|
||||
t.Error("expected no auth registration when Authorization header is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPluginAuthClient(t *testing.T) {
|
||||
base := transport.NewClient(nil)
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/mcp",
|
||||
AuthHeaders: map[string]string{
|
||||
"Authorization": "Bearer sk-test-api-key",
|
||||
"X-Extra": "extra-value",
|
||||
},
|
||||
}
|
||||
|
||||
client := buildPluginAuthClient(base, srv)
|
||||
|
||||
// Should return a different client instance
|
||||
if client == base {
|
||||
t.Error("expected buildPluginAuthClient to return a new client, not the base")
|
||||
}
|
||||
|
||||
// Verify trusted domains
|
||||
if len(client.TrustedDomains) != 2 {
|
||||
t.Fatalf("TrustedDomains len = %d, want 2", len(client.TrustedDomains))
|
||||
}
|
||||
if client.TrustedDomains[0] != "dashscope.aliyuncs.com" {
|
||||
t.Errorf("TrustedDomains[0] = %q, want dashscope.aliyuncs.com", client.TrustedDomains[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPluginAuthClientNoAuth(t *testing.T) {
|
||||
base := transport.NewClient(nil)
|
||||
|
||||
srv := market.ServerDescriptor{
|
||||
Endpoint: "https://api.example.com/mcp/v1",
|
||||
AuthHeaders: map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
client := buildPluginAuthClient(base, srv)
|
||||
|
||||
// Should return the base client when no Authorization header
|
||||
if client != base {
|
||||
t.Error("expected buildPluginAuthClient to return base client when no Authorization header")
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -16,8 +16,21 @@ package app
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CONFIG_DIR",
|
||||
Category: configmeta.CategoryCore,
|
||||
Description: "覆盖默认配置目录 (~/.dws)",
|
||||
DefaultValue: "~/.dws",
|
||||
Example: "/opt/dws/config",
|
||||
})
|
||||
}
|
||||
|
||||
// Build-time variables injected via ldflags when available.
|
||||
var (
|
||||
buildTime = "unknown"
|
||||
@@ -28,7 +41,9 @@ func defaultConfigDir() string {
|
||||
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
|
||||
if fn := edition.Get().ConfigDir; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return exeRelativeConfigDir()
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "配置管理",
|
||||
Long: "管理 DWS CLI 的配置项。查看所有支持的环境变量及其当前值。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newConfigListCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出所有可用配置项",
|
||||
Long: "显示 DWS CLI 支持的全部环境变量配置项,包括名称、分类、描述和默认值。",
|
||||
RunE: runConfigList,
|
||||
}
|
||||
cmd.Flags().String("category", "", "按分类过滤 (core|auth|network|security|runtime|debug|external)")
|
||||
cmd.Flags().Bool("show-values", false, "显示配置项的当前实际值 (敏感信息会脱敏)")
|
||||
cmd.Flags().Bool("show-hidden", false, "包含隐藏的内部调试配置项")
|
||||
cmd.Flags().Bool("json", false, "以 JSON 格式输出")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigList(cmd *cobra.Command, _ []string) error {
|
||||
category, _ := cmd.Flags().GetString("category")
|
||||
showValues, _ := cmd.Flags().GetBool("show-values")
|
||||
showHidden, _ := cmd.Flags().GetBool("show-hidden")
|
||||
jsonOut, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
var items []configmeta.ConfigItem
|
||||
if category != "" {
|
||||
items = configmeta.ByCategory(configmeta.Category(category))
|
||||
} else {
|
||||
items = configmeta.All()
|
||||
}
|
||||
|
||||
if !showHidden {
|
||||
items = filterVisible(items)
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
return writeConfigJSON(cmd, items, showValues)
|
||||
}
|
||||
return writeConfigTable(cmd, items, showValues)
|
||||
}
|
||||
|
||||
func filterVisible(items []configmeta.ConfigItem) []configmeta.ConfigItem {
|
||||
out := make([]configmeta.ConfigItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Hidden {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeConfigJSON(cmd *cobra.Command, items []configmeta.ConfigItem, showValues bool) error {
|
||||
type jsonItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
DefaultValue string `json:"default_value,omitempty"`
|
||||
Example string `json:"example,omitempty"`
|
||||
Sensitive bool `json:"sensitive,omitempty"`
|
||||
CurrentValue string `json:"current_value,omitempty"`
|
||||
IsSet bool `json:"is_set"`
|
||||
}
|
||||
|
||||
result := make([]jsonItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
ji := jsonItem{
|
||||
Name: item.Name,
|
||||
Category: string(item.Category),
|
||||
Description: item.Description,
|
||||
DefaultValue: item.DefaultValue,
|
||||
Example: item.Example,
|
||||
Sensitive: item.Sensitive,
|
||||
}
|
||||
val, ok := configmeta.Resolve(item.Name)
|
||||
ji.IsSet = ok
|
||||
if showValues && ok {
|
||||
ji.CurrentValue = val
|
||||
}
|
||||
result = append(result, ji)
|
||||
}
|
||||
|
||||
return output.WriteJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"kind": "config_list",
|
||||
"count": len(result),
|
||||
"configs": result,
|
||||
})
|
||||
}
|
||||
|
||||
func writeConfigTable(cmd *cobra.Command, items []configmeta.ConfigItem, showValues bool) error {
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
if len(items) == 0 {
|
||||
_, _ = fmt.Fprintln(w, "没有找到匹配的配置项。")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
|
||||
if showValues {
|
||||
_, _ = fmt.Fprintln(tw, "分类\t配置项\t描述\t默认值\t当前值")
|
||||
_, _ = fmt.Fprintln(tw, "────\t──────\t────\t──────\t──────")
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(tw, "分类\t配置项\t描述\t默认值")
|
||||
_, _ = fmt.Fprintln(tw, "────\t──────\t────\t──────")
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
def := item.DefaultValue
|
||||
if def == "" {
|
||||
def = "(空)"
|
||||
}
|
||||
if showValues {
|
||||
val, ok := configmeta.Resolve(item.Name)
|
||||
display := "(未设置)"
|
||||
if ok {
|
||||
display = val
|
||||
}
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
item.Category, item.Name, item.Description, def, display)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
item.Category, item.Name, item.Description, def)
|
||||
}
|
||||
}
|
||||
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintf(w, "\n共 %d 个配置项。使用 --show-values 查看当前值,--show-hidden 显示隐藏项。\n", len(items))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
func seedTestConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
configmeta.Reset()
|
||||
t.Cleanup(configmeta.Reset)
|
||||
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CONFIG_DIR", Category: configmeta.CategoryCore,
|
||||
Description: "覆盖默认配置目录", DefaultValue: "~/.dws",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_SECRET", Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppSecret", Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CATALOG_FIXTURE", Category: configmeta.CategoryDebug,
|
||||
Description: "目录 Fixture 路径", Hidden: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigListTable(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CONFIG_DIR") {
|
||||
t.Error("expected DWS_CONFIG_DIR in output")
|
||||
}
|
||||
if !strings.Contains(out, "DWS_CLIENT_SECRET") {
|
||||
t.Error("expected DWS_CLIENT_SECRET in output")
|
||||
}
|
||||
// Hidden items should be excluded by default
|
||||
if strings.Contains(out, "DWS_CATALOG_FIXTURE") {
|
||||
t.Error("expected DWS_CATALOG_FIXTURE to be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListShowHidden(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--show-hidden"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CATALOG_FIXTURE") {
|
||||
t.Error("expected DWS_CATALOG_FIXTURE with --show-hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListCategory(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--category", "auth"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "DWS_CLIENT_SECRET") {
|
||||
t.Error("expected DWS_CLIENT_SECRET for auth category")
|
||||
}
|
||||
if strings.Contains(out, "DWS_CONFIG_DIR") {
|
||||
t.Error("DWS_CONFIG_DIR should not appear for auth category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListJSON(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--json", "--show-hidden"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
if result["kind"] != "config_list" {
|
||||
t.Errorf("expected kind=config_list, got %v", result["kind"])
|
||||
}
|
||||
count, ok := result["count"].(float64)
|
||||
if !ok || count != 3 {
|
||||
t.Errorf("expected count=3, got %v", result["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListShowValues(t *testing.T) {
|
||||
seedTestConfig(t)
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", "/custom/dir")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "supersecret123")
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{"--show-values"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "/custom/dir") {
|
||||
t.Error("expected actual value for DWS_CONFIG_DIR")
|
||||
}
|
||||
if strings.Contains(out, "supersecret123") {
|
||||
t.Error("sensitive value should be masked")
|
||||
}
|
||||
if !strings.Contains(out, "当前值") {
|
||||
t.Error("expected '当前值' column header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListEmpty(t *testing.T) {
|
||||
configmeta.Reset()
|
||||
defer configmeta.Reset()
|
||||
|
||||
cmd := newConfigListCommand()
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.SetOut(buf)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "没有找到") {
|
||||
t.Error("expected empty message")
|
||||
}
|
||||
}
|
||||
+219
-12
@@ -14,10 +14,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -38,6 +41,57 @@ var legacyDirectRuntimeAliases = map[string]string{
|
||||
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
)
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
DisplayName: defaultPATDisplayName,
|
||||
Endpoint: defaultPATMCPEndpoint(),
|
||||
CLI: market.CLIOverlay{
|
||||
ID: defaultPATProductID,
|
||||
Command: defaultPATProductID,
|
||||
Prefixes: []string{defaultPATProductID},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPATMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + "/server/" + defaultPATServerID
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
switch {
|
||||
case host == "mcp.dingtalk.com":
|
||||
host = "mcp-gw.dingtalk.com"
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
host = strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1)
|
||||
case strings.HasPrefix(host, "mcp."):
|
||||
host = strings.Replace(host, "mcp.", "mcp-gw.", 1)
|
||||
}
|
||||
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
@@ -48,6 +102,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
@@ -80,11 +135,19 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +157,47 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
@@ -106,6 +210,25 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
@@ -123,14 +246,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: product-level endpoint.
|
||||
// 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.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
@@ -141,6 +261,24 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: tool-level endpoint (fallback for unknown productID).
|
||||
// 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 {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == defaultPATProductID {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -150,13 +288,82 @@ func DirectRuntimeProductIDs() map[string]bool {
|
||||
dynamicMu.RLock()
|
||||
dp := dynamicProducts
|
||||
dynamicMu.RUnlock()
|
||||
ids := make(map[string]bool, len(dp))
|
||||
ids := make(map[string]bool, len(dp)+1)
|
||||
ids[defaultPATProductID] = true
|
||||
for key := range dp {
|
||||
ids[key] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// AppendDynamicServer adds a single server descriptor to the existing
|
||||
// dynamic server registry without replacing the current entries. This
|
||||
// is used by the plugin loader to inject plugin servers alongside
|
||||
// Market-discovered servers.
|
||||
func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
|
||||
if dynamicEndpoints == nil {
|
||||
dynamicEndpoints = make(map[string]string)
|
||||
}
|
||||
if dynamicProducts == nil {
|
||||
dynamicProducts = make(map[string]bool)
|
||||
}
|
||||
if dynamicAliases == nil {
|
||||
dynamicAliases = make(map[string]string)
|
||||
}
|
||||
if dynamicToolEndpoints == nil {
|
||||
dynamicToolEndpoints = make(map[string]string)
|
||||
}
|
||||
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
dynamicEndpoints[id] = endpoint
|
||||
dynamicProducts[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
dynamicEndpoints[alias] = endpoint
|
||||
dynamicProducts[alias] = true
|
||||
dynamicAliases[alias] = id
|
||||
}
|
||||
}
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint; skip to avoid overwriting the real owner's mapping.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDirectRuntimeProductID(productID string) string {
|
||||
dynamicMu.RLock()
|
||||
da := dynamicAliases
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// Regression for the chat/bot tool routing bug: when the `chat` envelope
|
||||
// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
|
||||
// `send_message_by_custom_robot`), those tool names must NOT be registered
|
||||
// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
|
||||
// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
|
||||
// even when the invocation's CanonicalProduct is "bot", causing the Portal to
|
||||
// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
|
||||
// tool.
|
||||
//
|
||||
// Owner (bot envelope) still registers the tool (no serverOverride on the bot
|
||||
// side), so product-level and tool-level lookups both resolve correctly.
|
||||
|
||||
const (
|
||||
testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
|
||||
testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
|
||||
)
|
||||
|
||||
// botDescriptor returns a minimal `bot` server descriptor that owns the
|
||||
// `search_my_robots` + `send_message_by_custom_robot` tools (no
|
||||
// serverOverride — bot is the real owner).
|
||||
func botDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testBotEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "bot",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_my_robots": {CLIName: "search"},
|
||||
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
|
||||
"add_robot_to_group": {CLIName: "add-bot"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// chatDescriptor returns a minimal `chat` server descriptor whose
|
||||
// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
|
||||
// chat-native tool (`search_groups_by_keyword`) that must remain routed to
|
||||
// chat's endpoint.
|
||||
func chatDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testChatEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "chat",
|
||||
Command: "chat",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_groups_by_keyword": {CLIName: "search"},
|
||||
"search_my_robots": {
|
||||
CLIName: "search",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"send_message_by_custom_robot": {
|
||||
CLIName: "send-by-webhook",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
"add_robot_to_group": {
|
||||
CLIName: "add-bot",
|
||||
ServerOverride: "bot",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// withCleanDynamicRegistry snapshots and restores the package-level dynamic
|
||||
// registries so parallel/other tests aren't affected by this case's mutations.
|
||||
func withCleanDynamicRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func assertEndpoint(t *testing.T, productID, toolName, want string) {
|
||||
t.Helper()
|
||||
got, ok := directRuntimeEndpoint(productID, toolName)
|
||||
if !ok {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
|
||||
// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
|
||||
// of registration order.
|
||||
func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "bot first, chat second",
|
||||
servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "chat first, bot second",
|
||||
servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Bot-owned tools must route to bot's endpoint even though chat
|
||||
// declares toolOverrides for them (with serverOverride="bot").
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
|
||||
|
||||
// Chat-native tools must still route to chat.
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
|
||||
// Product-level fallback for bot (no tool name) must also return
|
||||
// bot's endpoint.
|
||||
assertEndpoint(t, "bot", "", testBotEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
|
||||
// the plugin-injection path (`AppendDynamicServer`) which has the same
|
||||
// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
|
||||
// serverOverride entries must not overwrite bot's tool → endpoint mapping.
|
||||
func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
|
||||
orders := [][]market.ServerDescriptor{
|
||||
{botDescriptor(), chatDescriptor()},
|
||||
{chatDescriptor(), botDescriptor()},
|
||||
}
|
||||
|
||||
for _, servers := range orders {
|
||||
t.Run("", func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
for _, s := range servers {
|
||||
AppendDynamicServer(s)
|
||||
}
|
||||
|
||||
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
|
||||
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
|
||||
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Issue #219 regression tests: cross-product tool name collision ---
|
||||
//
|
||||
// When two different products register tools with the same name (e.g. drive
|
||||
// and doc both have "create_folder"), the product-level endpoint must win
|
||||
// when the caller already knows the productID. Otherwise the tool-level map
|
||||
// (last-writer-wins) routes the invocation to the wrong MCP server.
|
||||
|
||||
const (
|
||||
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
|
||||
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
|
||||
)
|
||||
|
||||
func driveDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDriveEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "drive",
|
||||
Command: "drive",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "mkdir"},
|
||||
"list_files": {CLIName: "list"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"get_upload_info": {CLIName: "upload-info"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func docDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDocEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "create", Group: "folder"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"search_documents": {CLIName: "search"},
|
||||
"list_nodes": {CLIName: "list"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
|
||||
// that when productID is known and has a registered endpoint, the product-level
|
||||
// endpoint is used even if the tool-level map points to a different server
|
||||
// (due to same-name tool collision). This is the core fix for issue #219.
|
||||
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "drive first, doc second",
|
||||
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "doc first, drive second",
|
||||
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Drive tools must always route to drive's endpoint regardless of
|
||||
// registration order — productID "drive" is known.
|
||||
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
|
||||
|
||||
// Doc tools must always route to doc's endpoint.
|
||||
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
|
||||
|
||||
// Product-level fallback (no tool name) still works.
|
||||
assertEndpoint(t, "drive", "", testDriveEndpoint)
|
||||
assertEndpoint(t, "doc", "", testDocEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command field first-writer-wins regression test ---
|
||||
//
|
||||
// When two plugins declare the same CLI.Command but different CLI.ID values,
|
||||
// AppendDynamicServer must NOT let the second registration overwrite the
|
||||
// command → endpoint mapping established by the first. The fix uses a simple
|
||||
// "if not exists" guard on dynamicEndpoints[cmd].
|
||||
|
||||
const (
|
||||
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
|
||||
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
|
||||
)
|
||||
|
||||
func firstPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testFirstEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-alpha",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func secondPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testSecondEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-beta",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
|
||||
// two plugins declare the same Command (but different IDs), only the first
|
||||
// registration takes effect for the command → endpoint mapping. The second
|
||||
// plugin's own id-based endpoint is unaffected.
|
||||
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
AppendDynamicServer(firstPluginDescriptor())
|
||||
AppendDynamicServer(secondPluginDescriptor())
|
||||
|
||||
// The command "shared-cmd" must resolve to the first plugin's endpoint.
|
||||
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
|
||||
|
||||
// Each plugin's own id-based endpoint is always unconditionally written.
|
||||
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
|
||||
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
|
||||
|
||||
// Command must appear in dynamicProducts (discovery) regardless.
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["shared-cmd"] {
|
||||
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-alpha"] {
|
||||
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-beta"] {
|
||||
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
|
||||
|
||||
// When productID is empty, tool-level endpoint is the only option.
|
||||
// The actual endpoint depends on registration order (last-writer-wins),
|
||||
// but the lookup must succeed.
|
||||
endpoint, ok := directRuntimeEndpoint("", "create_folder")
|
||||
if !ok {
|
||||
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
|
||||
}
|
||||
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
|
||||
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
|
||||
}
|
||||
|
||||
// Unique tools (no collision) still resolve via tool-level.
|
||||
assertEndpoint(t, "", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
|
||||
}
|
||||
@@ -1,6 +1,74 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
server := defaultPATServerDescriptor()
|
||||
if server.CLI.ID != "pat" {
|
||||
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
|
||||
}
|
||||
if server.DisplayName != "行为授权" {
|
||||
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
|
||||
}
|
||||
if server.Endpoint != defaultPATMCPEndpoint() {
|
||||
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
previousProducts := dynamicProducts
|
||||
dynamicProducts = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicProducts = previousProducts
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["pat"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", tmpDir)
|
||||
|
||||
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
customEndpoint := "https://example.com/server/custom-pat"
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: customEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "pat",
|
||||
Command: "pat",
|
||||
},
|
||||
},
|
||||
})
|
||||
assertEndpoint(t, "pat", "", customEndpoint)
|
||||
}
|
||||
|
||||
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
docProductID = "doc"
|
||||
docDownloadFileTool = "download_file"
|
||||
docGetDocumentInfoTool = "get_document_info"
|
||||
docAXLSExtension = "axls"
|
||||
)
|
||||
|
||||
func (r *runtimeRunner) preflightDocDownload(ctx context.Context, tc *transport.Client, endpoint string, invocation executor.Invocation) error {
|
||||
if !isDocDownloadInvocation(invocation) {
|
||||
return nil
|
||||
}
|
||||
nodeID := docDownloadNodeID(invocation.Params)
|
||||
if nodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
preflightStart := time.Now()
|
||||
info, err := tc.CallTool(ctx, endpoint, docGetDocumentInfoTool, map[string]any{"nodeId": nodeID})
|
||||
RecordTiming(ctx, "doc_download_preflight", time.Since(preflightStart))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if err := classify(info.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patCheck := apperrors.ClassifyPatAuthCheck(info.Content); patCheck != nil {
|
||||
return patCheck
|
||||
}
|
||||
if info.IsError {
|
||||
return apperrors.NewAPI(
|
||||
extractMCPErrorMessage(info),
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
if bizErr := detectBusinessError(info.Content); bizErr != "" {
|
||||
return apperrors.NewAPI(
|
||||
bizErr,
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.EqualFold(documentInfoExtension(info.Content), docAXLSExtension) {
|
||||
return unsupportedAXLSDownloadError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDocDownloadInvocation(invocation executor.Invocation) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), docProductID) &&
|
||||
strings.TrimSpace(invocation.Tool) == docDownloadFileTool
|
||||
}
|
||||
|
||||
func docDownloadNodeID(params map[string]any) string {
|
||||
for _, key := range []string{"nodeId", "node", "dentryUuid"} {
|
||||
if value, ok := params[key].(string); ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unsupportedAXLSDownloadError() error {
|
||||
return apperrors.NewValidation(
|
||||
"nodeId 指向的节点是钉钉表格(extension=axls),在线表格不支持直接下载。请使用 getRange 工具获取表格数据。",
|
||||
apperrors.WithOperation("doc.download_file.preflight"),
|
||||
apperrors.WithReason("unsupported_alidoc_extension"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("在线表格应先用 doc info 确认 extension,再改用表格 MCP 的 get_all_sheets / get_range 读取数据。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>", "使用表格 MCP get_all_sheets / get_range"),
|
||||
)
|
||||
}
|
||||
|
||||
func documentInfoExtension(content map[string]any) string {
|
||||
for _, path := range [][]string{
|
||||
{"result", "extension"},
|
||||
{"data", "extension"},
|
||||
{"extension"},
|
||||
} {
|
||||
if value := stringAtPath(content, path...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringAtPath(value any, path ...string) string {
|
||||
current := value
|
||||
for _, key := range path {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
current = object[key]
|
||||
}
|
||||
if text, ok := current.(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
// 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"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// checkStatus represents the outcome of a single doctor check.
|
||||
type checkStatus string
|
||||
|
||||
const (
|
||||
statusPass checkStatus = "pass"
|
||||
statusWarn checkStatus = "warn"
|
||||
statusFail checkStatus = "fail"
|
||||
)
|
||||
|
||||
// checkResult holds the outcome of a single doctor check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status checkStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
func newDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "环境健康检查",
|
||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runDoctor,
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "以 JSON 格式输出")
|
||||
cmd.Flags().Int("timeout", 10, "网络检查超时时间 (秒)")
|
||||
cmd.Flags().Bool("perf", false, "额外展示最近一次性能报告")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
jsonOut, _ := cmd.Flags().GetBool("json")
|
||||
timeout, _ := cmd.Flags().GetInt("timeout")
|
||||
if timeout <= 0 {
|
||||
timeout = 10
|
||||
}
|
||||
networkTimeout := time.Duration(timeout) * time.Second
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
checks := make([]checkResult, 0, 4)
|
||||
|
||||
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
|
||||
checks = append(checks, authResult)
|
||||
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
cacheResult := doctorCheckCache(w, jsonOut)
|
||||
checks = append(checks, cacheResult)
|
||||
|
||||
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
||||
checks = append(checks, versionResult)
|
||||
|
||||
showPerf, _ := cmd.Flags().GetBool("perf")
|
||||
if showPerf {
|
||||
perfResult := doctorCheckPerf(w, jsonOut)
|
||||
checks = append(checks, perfResult)
|
||||
}
|
||||
|
||||
pass, warn, fail := countResults(checks)
|
||||
|
||||
if jsonOut {
|
||||
result := map[string]any{
|
||||
"kind": "doctor",
|
||||
"checks": checks,
|
||||
"summary": map[string]int{
|
||||
"pass": pass,
|
||||
"warn": warn,
|
||||
"fail": fail,
|
||||
},
|
||||
}
|
||||
if showPerf {
|
||||
if report, err := LoadLatestReport(); err == nil {
|
||||
result["perf_report"] = report
|
||||
}
|
||||
}
|
||||
return output.WriteJSON(w, result)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail)
|
||||
if fail > 0 {
|
||||
return fmt.Errorf("诊断发现 %d 项失败", fail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Auth check ──────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查登录状态... ")
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
|
||||
data, err := provider.Status()
|
||||
if err != nil || data == nil {
|
||||
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
|
||||
if !edition.Get().IsEmbedded {
|
||||
r.Hint = "运行 dws auth login 进行登录"
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if data.IsAccessTokenValid() || data.IsRefreshTokenValid() {
|
||||
if !data.IsAccessTokenValid() {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
||||
cancel()
|
||||
if refreshErr != nil {
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusWarn,
|
||||
Message: "Refresh Token 有效, 但自动刷新 Access Token 失败",
|
||||
Hint: "运行 dws auth login 重新登录",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusPass,
|
||||
Message: "已登录",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{Name: "auth", Status: statusFail, Message: "登录已过期"}
|
||||
if !edition.Get().IsEmbedded {
|
||||
r.Hint = "运行 dws auth login 重新登录"
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Network check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查网络连通性... ")
|
||||
}
|
||||
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
httpClient := &http.Client{Timeout: timeout}
|
||||
client := market.NewClient(baseURL, httpClient)
|
||||
|
||||
start := time.Now()
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.FetchServers(reqCtx, 1)
|
||||
latency := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("mcp.dingtalk.com 不可达: %v", err),
|
||||
Hint: "请检查网络连接或代理设置",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("mcp.dingtalk.com 可达 (延迟 %dms)", latency.Milliseconds()),
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Cache check ─────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查缓存状态... ")
|
||||
}
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
files, _, err := cacheDirectoryStats(store.Root)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("缓存目录不可读: %v", err),
|
||||
Hint: "运行 dws cache clean 清理后重试",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
entries, _ := store.ListToolsCacheEntries(config.DefaultPartition)
|
||||
|
||||
if files == 0 && len(entries) == 0 {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusWarn,
|
||||
Message: "缓存为空 (首次使用)",
|
||||
Hint: "运行任意 dws 命令后将自动建立缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
staleCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Freshness == cache.FreshnessStale {
|
||||
staleCount++
|
||||
}
|
||||
}
|
||||
|
||||
if staleCount > 0 {
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusWarn,
|
||||
Message: fmt.Sprintf("%d 个文件, %d 个工具缓存, %d 个已过期", files, len(entries), staleCount),
|
||||
Hint: "运行 dws cache refresh 刷新缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%d 个文件, %d 个工具缓存", files, len(entries))
|
||||
if len(entries) > 0 {
|
||||
msg += ", 全部新鲜"
|
||||
}
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusPass,
|
||||
Message: msg,
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Version check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查版本更新... ")
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
|
||||
client := upgrade.NewClient()
|
||||
latest, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("无法获取最新版本: %v", err),
|
||||
Hint: "请检查网络连接",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if upgrade.NeedsUpgrade(currentVer, latest.Version) {
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
Status: statusWarn,
|
||||
Message: fmt.Sprintf("有新版本 (当前 %s, 最新 v%s)", ensureV(currentVer), latest.Version),
|
||||
Hint: "运行 dws upgrade 升级到最新版本",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "version",
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("已是最新版本 %s", ensureV(currentVer)),
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Output helpers ──────────────────────────────────────────────────────
|
||||
|
||||
func printCheckResult(w io.Writer, r checkResult) {
|
||||
icon := statusIcon(r.Status)
|
||||
fmt.Fprintf(w, "%s %s\n", icon, r.Message)
|
||||
if r.Hint != "" {
|
||||
fmt.Fprintf(w, " %s\n", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func statusIcon(s checkStatus) string {
|
||||
switch s {
|
||||
case statusPass:
|
||||
return "✅"
|
||||
case statusWarn:
|
||||
return "⚠️"
|
||||
case statusFail:
|
||||
return "❌"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func countResults(checks []checkResult) (pass, warn, fail int) {
|
||||
for _, c := range checks {
|
||||
switch c.Status {
|
||||
case statusPass:
|
||||
pass++
|
||||
case statusWarn:
|
||||
warn++
|
||||
case statusFail:
|
||||
fail++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── Perf report check ──────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查性能报告... ")
|
||||
}
|
||||
|
||||
report, err := LoadLatestReport()
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "perf",
|
||||
Status: statusWarn,
|
||||
Message: "未找到性能报告",
|
||||
Hint: "设置 DWS_PERF_REPORT=auto 后运行任意命令生成报告",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "perf",
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("报告可用 (%s, %s)", report.Command, report.Timestamp.Local().Format("2006-01-02 15:04")),
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
printPerfReportSummary(w, report)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func printPerfReportSummary(w io.Writer, report *PerfReport) {
|
||||
fmt.Fprintf(w, "\n最近一次性能报告 (%s, %s):\n",
|
||||
report.Command, report.Timestamp.Local().Format("2006-01-02 15:04"))
|
||||
|
||||
for _, p := range report.Phases {
|
||||
marker := ""
|
||||
if p.Name == report.Slowest {
|
||||
marker = " ← 最慢"
|
||||
}
|
||||
fmt.Fprintf(w, " %-25s %dms%s\n", p.Name, p.DurationMs, marker)
|
||||
}
|
||||
fmt.Fprintf(w, " %-25s ─────────\n", "─────────────────────────")
|
||||
fmt.Fprintf(w, " %-25s %dms (框架开销 %dms)\n", "总耗时", report.TotalMs, report.OverheadMs)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCountResults(t *testing.T) {
|
||||
checks := []checkResult{
|
||||
{Status: statusPass},
|
||||
{Status: statusPass},
|
||||
{Status: statusWarn},
|
||||
{Status: statusFail},
|
||||
}
|
||||
pass, warn, fail := countResults(checks)
|
||||
if pass != 2 || warn != 1 || fail != 1 {
|
||||
t.Errorf("expected (2,1,1), got (%d,%d,%d)", pass, warn, fail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountResultsAllPass(t *testing.T) {
|
||||
checks := []checkResult{
|
||||
{Status: statusPass},
|
||||
{Status: statusPass},
|
||||
}
|
||||
pass, warn, fail := countResults(checks)
|
||||
if pass != 2 || warn != 0 || fail != 0 {
|
||||
t.Errorf("expected (2,0,0), got (%d,%d,%d)", pass, warn, fail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIcon(t *testing.T) {
|
||||
tests := []struct {
|
||||
status checkStatus
|
||||
want string
|
||||
}{
|
||||
{statusPass, "✅"},
|
||||
{statusWarn, "⚠️"},
|
||||
{statusFail, "❌"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := statusIcon(tc.status)
|
||||
if got != tc.want {
|
||||
t.Errorf("statusIcon(%q) = %q, want %q", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintCheckResult(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := checkResult{
|
||||
Name: "test",
|
||||
Status: statusFail,
|
||||
Message: "something broke",
|
||||
Hint: "try fixing it",
|
||||
}
|
||||
printCheckResult(&buf, r)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "❌") {
|
||||
t.Error("expected fail icon")
|
||||
}
|
||||
if !strings.Contains(out, "something broke") {
|
||||
t.Error("expected message")
|
||||
}
|
||||
if !strings.Contains(out, "try fixing it") {
|
||||
t.Error("expected hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintCheckResultNoHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := checkResult{
|
||||
Name: "test",
|
||||
Status: statusPass,
|
||||
Message: "all good",
|
||||
}
|
||||
printCheckResult(&buf, r)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "✅") {
|
||||
t.Error("expected pass icon")
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("expected 1 line (no hint), got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, false)
|
||||
|
||||
if r.Status != statusWarn {
|
||||
t.Errorf("expected warn for empty cache, got %s", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "缓存为空") {
|
||||
t.Errorf("expected empty cache message, got %q", r.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusWarn {
|
||||
t.Errorf("expected warn for empty cache, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCommandStructure(t *testing.T) {
|
||||
cmd := newDoctorCommand()
|
||||
if cmd.Use != "doctor" {
|
||||
t.Errorf("Use = %q, want doctor", cmd.Use)
|
||||
}
|
||||
|
||||
jsonFlag := cmd.Flags().Lookup("json")
|
||||
if jsonFlag == nil {
|
||||
t.Error("expected --json flag")
|
||||
}
|
||||
timeoutFlag := cmd.Flags().Lookup("timeout")
|
||||
if timeoutFlag == nil {
|
||||
t.Error("expected --timeout flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckResultJSONMarshal(t *testing.T) {
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusPass,
|
||||
Message: "已登录",
|
||||
}
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed["name"] != "auth" {
|
||||
t.Errorf("expected name=auth, got %v", parsed["name"])
|
||||
}
|
||||
if parsed["status"] != "pass" {
|
||||
t.Errorf("expected status=pass, got %v", parsed["status"])
|
||||
}
|
||||
if _, hasHint := parsed["hint"]; hasHint {
|
||||
t.Error("empty hint should be omitted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
|
||||
orig := fmt.Errorf("unknown flag: --json")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Message != orig.Error() {
|
||||
t.Fatalf("Message = %q, want %q", ae.Message, orig.Error())
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
|
||||
}
|
||||
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
|
||||
t.Fatalf("Hint = %q", ae.Hint)
|
||||
}
|
||||
if ae.Cause != orig {
|
||||
t.Fatalf("Cause = %v, want orig", ae.Cause)
|
||||
}
|
||||
if !stderrors.Is(err, orig) {
|
||||
t.Fatal("errors.Is(err, orig) should hold via unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
orig := fmt.Errorf("unknown flag: --starttime1")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q", ae.Reason)
|
||||
}
|
||||
if strings.Contains(ae.Hint, "Space required") {
|
||||
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
|
||||
}
|
||||
if !strings.Contains(ae.Hint, "help") {
|
||||
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
|
||||
}
|
||||
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
|
||||
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -35,12 +36,12 @@ type GlobalFlags struct {
|
||||
}
|
||||
|
||||
func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", "Override OAuth client ID (DingTalk AppKey)")
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", "Override OAuth client secret (DingTalk AppSecret)")
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientID, "client-id", "", i18n.T("覆盖 OAuth 客户端 ID (钉钉 AppKey)"))
|
||||
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", i18n.T("覆盖 OAuth 客户端密钥 (钉钉 AppSecret)"))
|
||||
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
|
||||
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
|
||||
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty|ndjson|csv")
|
||||
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
|
||||
// the new access_token. It is intended for callers that have observed a
|
||||
// server-side rejection (HTTP 401 or business code such as
|
||||
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
|
||||
//
|
||||
// Steps:
|
||||
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
|
||||
// OAuthProvider.GetAccessToken's fast-path will miss.
|
||||
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
|
||||
// existing dual-layer lock (process + file) to serialize concurrent
|
||||
// refresh attempts across goroutines and processes.
|
||||
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
|
||||
// next resolveAuthToken call re-reads from disk.
|
||||
//
|
||||
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
|
||||
// is the only entry point that orchestrates "force refresh" semantics.
|
||||
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
return "", fmt.Errorf("config directory is empty")
|
||||
}
|
||||
if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
|
||||
return "", fmt.Errorf("mark access token stale: %w", err)
|
||||
}
|
||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tok, err := provider.GetAccessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
return "", fmt.Errorf("force refresh returned empty access token")
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
return tok, nil
|
||||
}
|
||||
@@ -216,10 +216,10 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandDoesNotRegisterUpgradeCommand(t *testing.T) {
|
||||
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd != nil {
|
||||
t.Fatalf("findCommand(upgrade) = %q, want nil", cmd.CommandPath())
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
|
||||
t.Fatal("upgrade command should be registered on root, but was not found")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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
|
||||
|
||||
// MCPIdentityHeaders returns the same header map used for MCP HTTP requests
|
||||
// (agent identity, env trace headers, edition MergeHeaders). Intended for
|
||||
// non-MCP transports such as the A2A gateway client.
|
||||
func MCPIdentityHeaders() map[string]string {
|
||||
return resolveIdentityHeaders()
|
||||
}
|
||||
+216
-29
@@ -29,21 +29,86 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
var commands []*cobra.Command
|
||||
// Generate commands dynamically from the market discovery API.
|
||||
if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
|
||||
commands = append(commands, dynamicCmds...)
|
||||
if fn := edition.Get().StaticServers; fn != nil {
|
||||
injectStaticServers(fn())
|
||||
// Static servers provided by the edition hook — skip Market discovery
|
||||
// entirely. The overlay registers its own product commands via
|
||||
// RegisterExtraCommands; we only add the open-source helpers here.
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
commands = append(commands, helpers.NewPublicCommands(runner)...)
|
||||
return mergeTopLevelCommands(commands)
|
||||
|
||||
dynamicCmds := loadDynamicCommands(ctx, runner)
|
||||
helperCmds := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
// same-named top-level products, helper-only leaves are grafted into the
|
||||
// dynamic tree via cmdutil.MergeHardcodedLeaves so the discovery envelope
|
||||
// remains the authority for leaves it declares, while hardcoded helpers can
|
||||
// still fill gaps the envelope did not cover (e.g. `chat message send-by-bot`
|
||||
// alongside the envelope's `chat message send`).
|
||||
//
|
||||
// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
|
||||
// on same-named top-level commands, which — at leaf conflicts — falls back to
|
||||
// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
|
||||
// typically expose more flags than the corresponding dynamic overlay leaves,
|
||||
// so a naive append would silently promote helper leaves over their dynamic
|
||||
// counterparts. MergeHardcodedLeaves avoids that by letting dynamic win every
|
||||
// leaf conflict, and only adding subtrees the dynamic side lacks.
|
||||
func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
|
||||
dynByName := make(map[string]*cobra.Command, len(dynamic))
|
||||
out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
|
||||
for _, c := range dynamic {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
dynByName[c.Name()] = c
|
||||
out = append(out, c)
|
||||
}
|
||||
for _, h := range helpers {
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
if dyn := dynByName[h.Name()]; dyn != nil {
|
||||
cmdutil.MergeHardcodedLeaves(dyn, h)
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectStaticServers converts edition.ServerInfo entries into
|
||||
// market.ServerDescriptor and feeds them into SetDynamicServers so the
|
||||
// direct-runtime endpoint resolver can find them.
|
||||
func injectStaticServers(servers []edition.ServerInfo) {
|
||||
descriptors := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
descriptors = append(descriptors, market.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
SetDynamicServers(descriptors)
|
||||
}
|
||||
|
||||
// loadDynamicCommands loads the server registry and generates CLI commands
|
||||
@@ -58,10 +123,55 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
//
|
||||
// Tests may override discoveryBaseURLOverride to redirect to a local server;
|
||||
// in that case the registry cache is always bypassed.
|
||||
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
// editionPartition returns the cache partition for the active edition.
|
||||
// Thin wrapper around config.EditionPartition; kept so the many existing
|
||||
// call sites in internal/app don't need to thread edition.Get() everywhere.
|
||||
func editionPartition() string {
|
||||
return config.EditionPartition(edition.Get().Name)
|
||||
}
|
||||
|
||||
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
|
||||
// loadDynamicCommands runs while building the command tree, before PersistentPreRun
|
||||
// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
|
||||
func discoveryTraceEnabled() bool {
|
||||
if IsPerfDebugEnabled() {
|
||||
return true
|
||||
}
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--debug" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, s := range servers {
|
||||
id := strings.TrimSpace(s.CLI.Command)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(s.CLI.ID)
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
const maxIDs = 48
|
||||
if len(out) > maxIDs {
|
||||
out = out[:maxIDs]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
store := cacheStoreFromEnv()
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
|
||||
// Bypass the registry cache when a fixture override is active.
|
||||
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
|
||||
@@ -70,13 +180,15 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
|
||||
|
||||
// --- Cache-first server registry ---
|
||||
cacheLoadStart := time.Now()
|
||||
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
|
||||
RecordTiming(ctx, "registry_cache", time.Since(cacheLoadStart))
|
||||
|
||||
var servers []market.ServerDescriptor
|
||||
now := store.Now().UTC()
|
||||
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
|
||||
|
||||
if usingCachedRegistry {
|
||||
slog.Debug("loadDynamicCommands: using cached registry", "servers", len(snapshot.Servers), "freshness", freshness)
|
||||
servers = snapshot.Servers
|
||||
// Only trigger async revalidation in production (no URL override).
|
||||
// Tests set discoveryBaseURLOverride and control cache expiry directly,
|
||||
@@ -86,45 +198,92 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
}
|
||||
}
|
||||
|
||||
if len(servers) > 0 && discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
|
||||
"partition", partition,
|
||||
"servers", len(servers),
|
||||
"registry_freshness", string(freshness))
|
||||
}
|
||||
|
||||
// Cache miss or bypassed: fetch from market API synchronously (first run only).
|
||||
if len(servers) == 0 {
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
if discoveryTraceEnabled() {
|
||||
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
|
||||
} else {
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
}
|
||||
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL)
|
||||
}
|
||||
}
|
||||
slog.Debug("loadDynamicCommands: fetching servers from market API", "base_url", baseURL)
|
||||
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
|
||||
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
fetchStart := time.Now()
|
||||
|
||||
resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
|
||||
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
|
||||
if fetchErr != nil {
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch failed",
|
||||
"partition", partition,
|
||||
"error", fetchErr.Error())
|
||||
}
|
||||
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
|
||||
// Degrade to stale cache if available (production only).
|
||||
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
|
||||
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
|
||||
servers = snapshot.Servers
|
||||
} else {
|
||||
return nil
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServers(resp, "market")
|
||||
slog.Debug("loadDynamicCommands: normalized servers", "count", len(servers))
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch ok",
|
||||
"partition", partition,
|
||||
"response_servers", len(resp.Servers),
|
||||
"metadata_count", resp.Metadata.Count,
|
||||
"normalized_servers", len(servers),
|
||||
"cli_command_ids", discoveryTraceServerIDs(servers))
|
||||
}
|
||||
// Persist fresh data (only in non-test mode).
|
||||
if useCache {
|
||||
saveStart := time.Now()
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
|
||||
}
|
||||
RecordTiming(ctx, "cache_save", time.Since(saveStart))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FallbackServers: safety net when Market discovery + cache both fail.
|
||||
if len(servers) == 0 {
|
||||
if fn := edition.Get().FallbackServers; fn != nil {
|
||||
if fb := fn(); len(fb) > 0 {
|
||||
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
|
||||
descriptors := editionmerge.FallbackToDescriptors(fb)
|
||||
descriptors = editionmerge.MergeSupplement(descriptors)
|
||||
SetDynamicServers(descriptors)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge edition-specific supplement servers (not in Market).
|
||||
servers = editionmerge.MergeSupplement(servers)
|
||||
// Inject dynamic server data for endpoint resolution
|
||||
SetDynamicServers(servers)
|
||||
|
||||
detailStart := time.Now()
|
||||
detailsByID := loadCachedDetailsFast(store, servers)
|
||||
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
|
||||
|
||||
buildStart := time.Now()
|
||||
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
|
||||
slog.Debug("loadDynamicCommands: built dynamic commands", "commands", len(cmds))
|
||||
RecordTiming(ctx, "build_commands", time.Since(buildStart))
|
||||
|
||||
return cmds
|
||||
}
|
||||
@@ -136,7 +295,7 @@ func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor
|
||||
if store == nil {
|
||||
return result
|
||||
}
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
for _, server := range servers {
|
||||
if server.DetailLocator.MCPID <= 0 {
|
||||
continue
|
||||
@@ -167,7 +326,7 @@ func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
partition := config.DefaultPartition
|
||||
partition := editionPartition()
|
||||
now := time.Now().UTC()
|
||||
if store != nil && store.Now != nil {
|
||||
now = store.Now().UTC()
|
||||
@@ -297,14 +456,16 @@ func DiscoveryBaseURL() string {
|
||||
return cli.DefaultMarketBaseURL
|
||||
}
|
||||
|
||||
// ipv4OnlyHTTPClient returns an HTTP client that forces IPv4 connections
|
||||
// and uses a short timeout suitable for CLI startup network requests.
|
||||
// This avoids IPv6 DNS/connect timeouts on hosts without IPv6 networking.
|
||||
func ipv4OnlyHTTPClient() *http.Client {
|
||||
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
|
||||
// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
|
||||
// hosts without IPv6 networking.
|
||||
func ipv4HTTPClient(timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 3 * time.Second}
|
||||
return &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
},
|
||||
@@ -312,6 +473,29 @@ func ipv4OnlyHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
|
||||
// suitable for CLI startup network requests.
|
||||
func ipv4OnlyHTTPClient() *http.Client {
|
||||
return ipv4HTTPClient(5 * time.Second)
|
||||
}
|
||||
|
||||
// fetchRegistryServers performs the server-list HTTP fetch honoring the
|
||||
// active edition's DiscoveryURL override. It is the single source of truth
|
||||
// for all server-list fetches (startup, async revalidation, explicit
|
||||
// `cache refresh`); keeping the edition-URL branch in one place prevents
|
||||
// call sites from drifting out of sync.
|
||||
func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
client := market.NewClient("", httpClient)
|
||||
if fn := edition.Get().DiscoveryHeaders; fn != nil {
|
||||
client.Headers = fn()
|
||||
}
|
||||
return client.FetchServersFromURL(ctx, editionURL)
|
||||
}
|
||||
client := market.NewClient(DiscoveryBaseURL(), httpClient)
|
||||
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -319,9 +503,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
|
||||
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
baseURL := DiscoveryBaseURL()
|
||||
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
|
||||
resp, err := client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
if err != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
@@ -362,3 +544,8 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSupplementServers / fallbackToDescriptors have moved to
|
||||
// internal/editionmerge so that both internal/cli and internal/app can
|
||||
// apply the edition's SupplementServers / FallbackServers hooks against
|
||||
// the same discovery pipeline (command tree + runtime catalog).
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPickCommands_DynamicWinsLeafConflicts verifies that when the discovery
|
||||
// envelope produces a dynamic leaf and a helper registers the same-named leaf,
|
||||
// the dynamic one wins — envelopes remain the runtime authority for behaviour
|
||||
// they declare. The helper subtree must not slip in via
|
||||
// mergeTopLevelCommands's LocalFlagCount-based arbitration.
|
||||
func TestPickCommands_DynamicWinsLeafConflicts(t *testing.T) {
|
||||
dynTask := &cobra.Command{Use: "task", Short: "dynamic-task", Run: func(*cobra.Command, []string) {}}
|
||||
dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
|
||||
dyn.AddCommand(dynTask)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
hlpTask := &cobra.Command{Use: "task", Short: "helper-task", Run: func(*cobra.Command, []string) {}}
|
||||
hlp := &cobra.Command{Use: "todo", Short: "helper"}
|
||||
hlp.AddCommand(hlpTask)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
// The dynamic leaf must still be the one we find under the top-level name.
|
||||
var found *cobra.Command
|
||||
for _, c := range got[0].Commands() {
|
||||
if c.Name() == "task" {
|
||||
found = c
|
||||
}
|
||||
}
|
||||
if found != dynTask {
|
||||
t.Fatalf("leaf conflict resolved to helper; want dynamic to win")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperOnlyLeavesAreGrafted verifies that when a helper
|
||||
// registers siblings the discovery envelope did NOT declare (e.g.
|
||||
// `chat message send-by-bot`, `chat message recall-by-bot` next to the
|
||||
// envelope's `chat message send`), those helper-only leaves are grafted into
|
||||
// the dynamic subtree instead of being dropped. This is a regression guard:
|
||||
// prior to this fix, pickCommands silently dropped the entire helper subtree
|
||||
// whenever the top-level product name collided, which disappeared every
|
||||
// helper-only leaf the envelope didn't cover.
|
||||
func TestPickCommands_HelperOnlyLeavesAreGrafted(t *testing.T) {
|
||||
dynMessage := &cobra.Command{Use: "message"}
|
||||
dynMessage.AddCommand(&cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}})
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynMessage)
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
helperOnlyLeaf := &cobra.Command{Use: "send-by-bot", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMessage := &cobra.Command{Use: "message"}
|
||||
hlpMessage.AddCommand(helperOnlyLeaf)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpMessage)
|
||||
helpers := []*cobra.Command{hlp}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("pickCommands returned %v, want [dyn]", got)
|
||||
}
|
||||
var grafted *cobra.Command
|
||||
for _, child := range dynMessage.Commands() {
|
||||
if child.Name() == "send-by-bot" {
|
||||
grafted = child
|
||||
}
|
||||
}
|
||||
if grafted == nil {
|
||||
t.Fatalf("helper-only leaf send-by-bot was not grafted into dynamic.chat.message")
|
||||
}
|
||||
if grafted != helperOnlyLeaf {
|
||||
t.Fatalf("grafted leaf identity differs from helper-registered leaf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
|
||||
// names are NOT in the dynamic set are preserved — the dynamic overlay only
|
||||
// shadows products it actually covers.
|
||||
func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
dynamic := []*cobra.Command{dyn}
|
||||
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
attendanceHelper := &cobra.Command{Use: "attendance"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
|
||||
|
||||
got := pickCommands(dynamic, helpers)
|
||||
|
||||
names := make(map[string]*cobra.Command, len(got))
|
||||
for _, c := range got {
|
||||
names[c.Name()] = c
|
||||
}
|
||||
if names["todo"] != dyn {
|
||||
t.Fatalf("todo = %v, want dynamic", names["todo"])
|
||||
}
|
||||
if names["attendance"] != attendanceHelper {
|
||||
t.Fatalf("attendance not preserved from helpers")
|
||||
}
|
||||
if names["chat"] != chatHelper {
|
||||
t.Fatalf("chat not preserved from helpers")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
|
||||
// when discovery returns nothing, helpers are the sole source of truth — the
|
||||
// behaviour must be identical to the pre-refactor append-all code path.
|
||||
func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
|
||||
todoHelper := &cobra.Command{Use: "todo"}
|
||||
chatHelper := &cobra.Command{Use: "chat"}
|
||||
helpers := []*cobra.Command{todoHelper, chatHelper}
|
||||
|
||||
got := pickCommands(nil, helpers)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2", len(got))
|
||||
}
|
||||
if got[0] != todoHelper || got[1] != chatHelper {
|
||||
t.Fatalf("pickCommands changed helpers order or identity")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_HelperGroupShadowsDynamicLeaf simulates the issue #164
|
||||
// shape mismatch: the discovery envelope publishes `chat group members` as
|
||||
// a LEAF (the get_group_members tool exposed at that CLI path), while the
|
||||
// hardcoded helper has restructured `members` into a GROUP container with
|
||||
// `list / add / remove / add-bot` subcommands. The helper group carries the
|
||||
// preferLegacyLeaf priority annotation, so it must replace the dynamic leaf
|
||||
// and surface its subtree — otherwise `dws chat group members list` is
|
||||
// unreachable and the user-visible regression in #164 stays.
|
||||
func TestPickCommands_HelperGroupShadowsDynamicLeaf(t *testing.T) {
|
||||
dynMembers := &cobra.Command{Use: "members", Run: func(*cobra.Command, []string) {}}
|
||||
dynMembers.Flags().String("id", "", "")
|
||||
dynGroup := &cobra.Command{Use: "group"}
|
||||
dynGroup.AddCommand(dynMembers)
|
||||
dyn := &cobra.Command{Use: "chat"}
|
||||
dyn.AddCommand(dynGroup)
|
||||
|
||||
hlpList := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
hlpList.Flags().String("id", "", "")
|
||||
hlpAdd := &cobra.Command{Use: "add", Run: func(*cobra.Command, []string) {}}
|
||||
hlpRemove := &cobra.Command{Use: "remove", Run: func(*cobra.Command, []string) {}}
|
||||
hlpMembers := &cobra.Command{Use: "members"}
|
||||
hlpMembers.AddCommand(hlpList, hlpAdd, hlpRemove)
|
||||
cobracmd.SetOverridePriority(hlpMembers, 100)
|
||||
hlpGroup := &cobra.Command{Use: "group"}
|
||||
hlpGroup.AddCommand(hlpMembers)
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
hlp.AddCommand(hlpGroup)
|
||||
|
||||
got := pickCommands([]*cobra.Command{dyn}, []*cobra.Command{hlp})
|
||||
if len(got) != 1 || got[0] != dyn {
|
||||
t.Fatalf("got %v, want [dyn]", got)
|
||||
}
|
||||
|
||||
// Locate the (potentially replaced) members node under chat.group.
|
||||
var members *cobra.Command
|
||||
for _, c := range dynGroup.Commands() {
|
||||
if c.Name() == "members" {
|
||||
members = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if members == nil {
|
||||
t.Fatalf("members node missing under dyn.chat.group after merge")
|
||||
}
|
||||
|
||||
want := map[string]bool{"list": false, "add": false, "remove": false}
|
||||
for _, sub := range members.Commands() {
|
||||
if _, ok := want[sub.Name()]; ok {
|
||||
want[sub.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("expected `chat group members %s` after merge, missing", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
|
||||
// a misbehaving factory.
|
||||
func TestPickCommands_NilsAreSkipped(t *testing.T) {
|
||||
dyn := &cobra.Command{Use: "todo"}
|
||||
hlp := &cobra.Command{Use: "chat"}
|
||||
|
||||
got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
|
||||
}
|
||||
if got[0] != dyn || got[1] != hlp {
|
||||
t.Fatalf("unexpected ordering or identity after nil filter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// TestEditionPartition_SingleSourceOfTruth is the regression test that
|
||||
// specifically targets the original bug: internal/app.loadDynamicCommands
|
||||
// was computing its partition one way (editionPartition() →
|
||||
// "wukong/default") while internal/cli.EnvironmentLoader was hardcoding
|
||||
// config.DefaultPartition ("default/default"). This meant runtime endpoint
|
||||
// resolution and command-tree generation read different cache files, and
|
||||
// under gray-release the two partitions carried disjoint product lists —
|
||||
// the historical root cause of `dws conference meeting create` failing
|
||||
// while `dws todo task list` succeeded on the same host.
|
||||
//
|
||||
// Keeping both sides funneled through config.EditionPartition is the
|
||||
// central invariant the fix enforces. If this test ever regresses, the
|
||||
// two-partition split almost certainly came back.
|
||||
func TestEditionPartition_SingleSourceOfTruth(t *testing.T) {
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
edition string
|
||||
want string
|
||||
}{
|
||||
{"open edition falls through to default/default", "", config.DefaultPartition},
|
||||
{"explicit open edition remains default", "open", config.DefaultPartition},
|
||||
{"wukong overlay uses wukong/default", "wukong", "wukong/default"},
|
||||
{"custom edition is namespaced", "internal-lab", "internal-lab/default"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
edition.Override(&edition.Hooks{Name: tc.edition})
|
||||
legacy := editionPartition()
|
||||
shared := config.EditionPartition(edition.Get().Name)
|
||||
|
||||
if legacy != shared {
|
||||
t.Fatalf("editionPartition()=%q, config.EditionPartition()=%q — partition split regressed for edition %q", legacy, shared, tc.edition)
|
||||
}
|
||||
if legacy != tc.want {
|
||||
t.Fatalf("editionPartition()=%q, want %q for edition %q", legacy, tc.want, tc.edition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const (
|
||||
// PatAuthRetryTimeout is the maximum time to wait for user authorization
|
||||
// when a PAT scope error is detected.
|
||||
PatAuthRetryTimeout = 10 * time.Minute
|
||||
|
||||
// PatAuthPollInterval is how often we poll to check if the user has
|
||||
// completed authorization.
|
||||
PatAuthPollInterval = 5 * time.Second
|
||||
|
||||
patScopeAuthRequiredCode = "PAT_SCOPE_AUTH_REQUIRED"
|
||||
)
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
Identity string
|
||||
ErrorType string
|
||||
Message string
|
||||
Hint string
|
||||
MissingScope string
|
||||
}
|
||||
|
||||
func (e *PatScopeError) Error() string {
|
||||
return e.OriginalError
|
||||
}
|
||||
|
||||
// patScopeRegex matches PAT-protocol scope error patterns from the API.
|
||||
// Only matches explicit scope-related keywords; generic "permission denied" or
|
||||
// "forbidden" are intentionally excluded to avoid false positives on business
|
||||
// authorization errors (e.g. mailbox access denied, 403 Forbidden).
|
||||
var patScopeRegex = regexp.MustCompile(`(?i)(missing_scope|insufficient_scope|scope.*required)`)
|
||||
|
||||
// scopeValueRegex extracts a scope identifier (e.g. "calendar:read",
|
||||
// "mail:user_mailbox.message:send") from an error message.
|
||||
// Supports multi-segment scopes with multiple colons (resource:sub:action).
|
||||
var scopeValueRegex = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_.]*(?::[a-zA-Z][a-zA-Z0-9_.]*)+)`)
|
||||
|
||||
// identityValueRegex extracts an identity label from an error message.
|
||||
var identityValueRegex = regexp.MustCompile(`(?i)identity["\s:]+([a-zA-Z_]+)`)
|
||||
|
||||
// isPatScopeError checks if an error looks like a PAT scope/permission error
|
||||
// that can be resolved by re-authorizing with additional scopes.
|
||||
func isPatScopeError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
|
||||
// Check for missing_scope pattern in error message or hint
|
||||
if patScopeRegex.MatchString(msg) {
|
||||
return true
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) {
|
||||
// Check message, reason, and hint for scope-related patterns
|
||||
fullText := strings.ToLower(typed.Message + " " + typed.Reason + " " + typed.Hint)
|
||||
if typed.Category == apperrors.CategoryAuth {
|
||||
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") ||
|
||||
(strings.Contains(fullText, "scope") && strings.Contains(fullText, "required")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Any category with scope/permission hints
|
||||
if strings.Contains(fullText, "missing_scope") || strings.Contains(fullText, "insufficient_scope") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPatScopeError parses an error to extract PAT scope details.
|
||||
func extractPatScopeError(err error) *PatScopeError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
scope := ""
|
||||
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(err, &typed) {
|
||||
msg = typed.Message
|
||||
if typed.Reason != "" {
|
||||
msg += " (" + typed.Reason + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract scope value (e.g. "calendar:read") from error message.
|
||||
scopeMatch := scopeValueRegex.FindStringSubmatch(msg)
|
||||
if len(scopeMatch) > 1 {
|
||||
scope = scopeMatch[1]
|
||||
}
|
||||
|
||||
// Try to extract identity from error message.
|
||||
identity := "user"
|
||||
identityMatch := identityValueRegex.FindStringSubmatch(msg)
|
||||
if len(identityMatch) > 1 {
|
||||
identity = identityMatch[1]
|
||||
}
|
||||
|
||||
return &PatScopeError{
|
||||
OriginalError: err.Error(),
|
||||
Identity: identity,
|
||||
ErrorType: "missing_scope",
|
||||
Message: msg,
|
||||
Hint: fmt.Sprintf("run `dws auth login --scope %q` to authorize the missing scope", scope),
|
||||
MissingScope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
// 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, " }\n")
|
||||
fmt.Fprintf(w, "}\n")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Print authorization instructions
|
||||
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
|
||||
|
||||
if scopeErr.MissingScope != "" {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// PrintPatAuthJSON prints a machine-readable PAT authorization error.
|
||||
func PrintPatAuthJSON(w io.Writer, scopeErr *PatScopeError) {
|
||||
fmt.Fprintln(w, buildPATScopeJSON(scopeErr, authpkg.HostOwnsPATFlow()))
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutput(r *runtimeRunner) bool {
|
||||
if r == nil || r.globalFlags == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(r.globalFlags.Format), "json")
|
||||
}
|
||||
|
||||
func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||||
rr, ok := runner.(*runtimeRunner)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return wantsStructuredPATOutput(rr)
|
||||
}
|
||||
|
||||
func currentPATOpenBrowser(configDir string) bool {
|
||||
return pat.EffectiveOpenBrowser(configDir)
|
||||
}
|
||||
|
||||
func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
if !ok || data == nil {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// 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)
|
||||
defer pollTicker.Stop()
|
||||
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.Fprintln(output)
|
||||
|
||||
pollCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
|
||||
return false
|
||||
|
||||
case <-time.After(time.Until(deadline)):
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
|
||||
return false
|
||||
|
||||
case <-pollTicker.C:
|
||||
pollCount++
|
||||
elapsed := time.Since(start).Truncate(time.Second)
|
||||
remaining := time.Until(deadline).Truncate(time.Second)
|
||||
|
||||
// Check if token is now valid
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
green("✓"), bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Show polling status
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
|
||||
dim("⟳"), pollCount, elapsed, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryWithPatAuthRetry wraps an invocation that failed with a PAT scope error.
|
||||
// It waits for the user to complete authorization and then retries the invocation.
|
||||
func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocation executor.Invocation, scopeErr *PatScopeError, configDir string, output io.Writer) (executor.Result, error) {
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "retryWithPatAuthRetry",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, true)}
|
||||
}
|
||||
if wantsStructuredPATOutputFromRunner(runner) {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: buildPATScopeJSON(scopeErr, false)}
|
||||
}
|
||||
|
||||
// Print the PAT error in human-readable format
|
||||
PrintPatAuthError(output, scopeErr)
|
||||
|
||||
// Wait for user to complete authorization
|
||||
authorized := WaitForPatAuthorization(ctx, configDir, output)
|
||||
if !authorized {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"等待用户授权超时",
|
||||
apperrors.WithReason("pat_auth_timeout"),
|
||||
apperrors.WithHint(fmt.Sprintf("授权超时 (%s),请重新执行命令", PatAuthRetryTimeout)),
|
||||
apperrors.WithActions("dws auth login"),
|
||||
)
|
||||
}
|
||||
|
||||
// Clear the token cache so the new token is loaded
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
// Retry the invocation
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
|
||||
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
return runner.Run(ctx, invocation)
|
||||
}
|
||||
|
||||
// ---- handlePatAuthCheck (runner.go entry point) -----------------------------
|
||||
|
||||
const (
|
||||
// patPollInterval is how often we poll the device flow status endpoint.
|
||||
patPollInterval = 2 * time.Second
|
||||
// patPollTimeout is the maximum time to wait for user authorization via device flow.
|
||||
patPollTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// patRetryingKey is a context key to prevent recursive PAT auth checks.
|
||||
// After APPROVED, the retry should not trigger another PAT flow.
|
||||
type patRetryingKeyType struct{}
|
||||
|
||||
var patRetryingKey = patRetryingKeyType{}
|
||||
|
||||
// IsPatRetrying returns true if the current context is already in a PAT retry.
|
||||
func IsPatRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(patRetryingKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func openPATAuthorizationURI(rawURI string) error {
|
||||
if rawURI == "" {
|
||||
// Defensive guard for future callers. The current call site already
|
||||
// checks for a non-empty PAT URI before invoking this helper.
|
||||
return nil
|
||||
}
|
||||
// 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.
|
||||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||||
}
|
||||
|
||||
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
if os.Getenv("DWS_DEBUG_PAT_POLL") == "" {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
trimmed = "<empty body>"
|
||||
}
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, " ℹ PAT 轮询接口返回原文 (HTTP %d):\n", statusCode)
|
||||
fmt.Fprintf(output, " %s\n", trimmed)
|
||||
}
|
||||
|
||||
// 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,
|
||||
// polls the device flow endpoint until the user authorizes, and retries the
|
||||
// original invocation on success.
|
||||
func handlePatAuthCheck(
|
||||
ctx context.Context,
|
||||
r *runtimeRunner,
|
||||
invocation executor.Invocation,
|
||||
patErr *apperrors.PATError,
|
||||
configDir string,
|
||||
output io.Writer,
|
||||
) (executor.Result, error) {
|
||||
// Parse authorization details from PATError.RawJSON.
|
||||
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"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
slog.Debug("PAT auth check",
|
||||
"clientId", patData.Data.ClientID,
|
||||
"flowId", patData.Data.FlowID,
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
openBrowser := currentPATOpenBrowser(configDir)
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "handlePatAuthCheck",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
"agentCodeEnvSet", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
|
||||
// Inject clientId/clientSecret from PAT response as runtime credentials
|
||||
// so that subsequent device flow auth uses the server-assigned app identity.
|
||||
var appCfg *authpkg.AppConfig
|
||||
if patData.Data.ClientID != "" {
|
||||
if patData.Data.ClientSecret != "" {
|
||||
// When both clientId and clientSecret are provided, use direct mode
|
||||
// (DingTalk API) rather than MCP proxy — the MCP proxy does not hold
|
||||
// the secret for this particular app.
|
||||
authpkg.SetClientID(patData.Data.ClientID)
|
||||
authpkg.SetClientSecret(patData.Data.ClientSecret)
|
||||
} else {
|
||||
// No clientSecret — rely on MCP proxy to manage the secret server-side.
|
||||
authpkg.SetClientIDFromMCP(patData.Data.ClientID)
|
||||
}
|
||||
|
||||
// Persist only after an explicit APPROVED result below. Raw PAT
|
||||
// interceptions (host-owned / json / empty-flow pass-through) must not
|
||||
// rewrite the shared ~/.dws/app.json state for unrelated shells or agents.
|
||||
appCfg = &authpkg.AppConfig{ClientID: patData.Data.ClientID}
|
||||
if patData.Data.ClientSecret != "" {
|
||||
appCfg.ClientSecret = authpkg.PlainSecret(patData.Data.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
|
||||
// or when flowId is absent, the CLI returns machine-readable JSON to
|
||||
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
|
||||
// used for this decision — it is only forwarded on the wire via
|
||||
// edition.MergeHeaders and surfaced in hostControl for traceability.
|
||||
if hostOwnedPAT || patData.Data.FlowID == "" {
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
if wantsStructuredPATOutput(r) {
|
||||
if openBrowser && patData.Data.URI != "" {
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
greenFn := color.New(color.FgGreen).SprintFunc()
|
||||
yellowFn := color.New(color.FgYellow).SprintFunc()
|
||||
redFn := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
|
||||
if patData.Data.Desc != "" {
|
||||
fmt.Fprintf(output, " %s %s\n", 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)
|
||||
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.Fprintln(output)
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
|
||||
if err != nil {
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
if appCfg != nil {
|
||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
||||
slog.Warn("failed to persist approved app config from PAT", "error", err)
|
||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
|
||||
if authCode != "" {
|
||||
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
|
||||
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)
|
||||
} 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)
|
||||
} else {
|
||||
slog.Debug("PAT retry: token refreshed and saved")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear token cache so the new credentials take effect.
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
// 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.Fprintln(output)
|
||||
slog.Debug("PAT retry: identity env check",
|
||||
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
|
||||
)
|
||||
retryCtx := context.WithValue(ctx, patRetryingKey, true)
|
||||
return r.Run(retryCtx, invocation)
|
||||
|
||||
case authpkg.StatusRejected:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"用户已拒绝授权",
|
||||
apperrors.WithReason("pat_auth_rejected"),
|
||||
apperrors.WithHint("用户在浏览器中拒绝了授权请求,请重新执行命令。"),
|
||||
)
|
||||
|
||||
case authpkg.StatusExpired:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"授权超时",
|
||||
apperrors.WithReason("pat_auth_expired"),
|
||||
apperrors.WithHint("授权链接已过期,请重新执行命令。"),
|
||||
)
|
||||
|
||||
case authpkg.StatusCancelled:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"操作已取消",
|
||||
apperrors.WithReason("pat_auth_cancelled"),
|
||||
apperrors.WithHint("用户取消了授权操作。"),
|
||||
)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
}
|
||||
|
||||
func enrichPATErrorForHostControl(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
// Route back through the classifier so host-owned active retry emits the
|
||||
// exact same PAT JSON shape as passive classification.
|
||||
if patErr := apperrors.ClassifyPatAuthCheck(payload); patErr != nil {
|
||||
return patErr.RawJSON
|
||||
}
|
||||
|
||||
apperrors.ApplyHostMutations(payload)
|
||||
|
||||
// stderr JSON MUST be single-line.
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// buildPATScopeJSON renders the PAT_SCOPE_AUTH_REQUIRED stderr payload.
|
||||
// includeHostControl=true follows the standard host-owned/CLI-owned split
|
||||
// (data.hostControl is injected only if HostControlBlock is non-nil).
|
||||
// includeHostControl=false is an explicit override used by the CLI-owned
|
||||
// branch so that any env-mode misconfiguration cannot leak a host-owned
|
||||
// contract into stderr.
|
||||
func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string {
|
||||
data := map[string]any{
|
||||
"identity": scopeErr.Identity,
|
||||
"errorType": scopeErr.ErrorType,
|
||||
"message": scopeErr.Message,
|
||||
"hint": scopeErr.Hint,
|
||||
"missingScope": scopeErr.MissingScope,
|
||||
"openBrowser": apperrors.PATOpenBrowserValue(),
|
||||
}
|
||||
if includeHostControl {
|
||||
if hostControl := apperrors.HostControlBlock(); hostControl != nil {
|
||||
data["hostControl"] = hostControl
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"success": false,
|
||||
"code": patScopeAuthRequiredCode,
|
||||
"data": data,
|
||||
}
|
||||
// stderr JSON MUST be single-line.
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||
|
||||
// Load user access token for the poll request header.
|
||||
var accessToken string
|
||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||||
accessToken = tokenData.AccessToken
|
||||
}
|
||||
|
||||
// Use a client that does NOT follow redirects, so we can detect SSO 302.
|
||||
noRedirectClient := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(patPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.Canceled {
|
||||
return authpkg.StatusCancelled, "", nil
|
||||
}
|
||||
return authpkg.StatusExpired, "", nil
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: failed to create request", "error", err)
|
||||
continue
|
||||
}
|
||||
if accessToken != "" {
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
}
|
||||
resp, err := noRedirectClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("PAT poll: request failed", "error", err)
|
||||
continue // transient network error, keep polling
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
// If we got a redirect (302/301), SSO gateway intercepted — skip JSON parse.
|
||||
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently {
|
||||
continue
|
||||
}
|
||||
|
||||
var pollResp authpkg.DevicePollResponse
|
||||
if err := json.Unmarshal(bodyBytes, &pollResp); err != nil {
|
||||
slog.Debug("PAT poll: failed to parse response", "error", err, "body", string(bodyBytes))
|
||||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||||
continue
|
||||
}
|
||||
|
||||
pollData := pollResp.EffectiveData()
|
||||
status := authpkg.ParseDeviceFlowStatus(pollData.Status, pollResp.Success)
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, pollData.AuthCode, nil
|
||||
case authpkg.StatusRejected, authpkg.StatusExpired:
|
||||
fmt.Fprintln(output) // clear the polling line
|
||||
return status, "", nil
|
||||
case authpkg.StatusPending:
|
||||
default:
|
||||
// ParseDeviceFlowStatus normalizes empty+!success to EXPIRED,
|
||||
// so this branch handles truly unknown statuses.
|
||||
fmt.Fprintln(output)
|
||||
printPATPollDebugResponse(output, resp.StatusCode, bodyBytes)
|
||||
return status, "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return exec.Command("open", rawURL)
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", rawURL)
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(rawURL string) error {
|
||||
cmd := browserOpenCommand(runtime.GOOS, rawURL)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
// 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 (
|
||||
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/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// init wires the PAT classifier's hostControl injection hook. This
|
||||
// guarantees cleanPATJSON emits data.hostControl in host-owned mode
|
||||
// regardless of whether the PAT error was surfaced via the active retry
|
||||
// path or the passive classifier path.
|
||||
//
|
||||
// Decision rule:
|
||||
// - Host-owned is triggered iff DINGTALK_DWS_AGENTCODE is non-empty.
|
||||
// - When triggered, `clawType` in the emitted hostControl block MUST
|
||||
// be the exact value the CLI actually injects on the wire into the
|
||||
// `claw-type` HTTP header. The open-source build pins that to
|
||||
// edition.DefaultOSSClawType ("openClaw") unconditionally — there
|
||||
// is no per-spawn env override.
|
||||
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
|
||||
// HostControlBlock yields nil and no hostControl block is emitted.
|
||||
func init() {
|
||||
apperrors.SetHostControlProvider(hostControlProviderFromEnv)
|
||||
apperrors.SetPATOpenBrowserProvider(func() bool {
|
||||
return pat.EffectiveOpenBrowser(defaultConfigDir())
|
||||
})
|
||||
}
|
||||
|
||||
func hostControlProviderFromEnv() string {
|
||||
if !authpkg.HostOwnsPATFlow() {
|
||||
return ""
|
||||
}
|
||||
return effectiveClawType()
|
||||
}
|
||||
|
||||
// effectiveClawType returns the literal value that MergeHeaders will
|
||||
// inject into outbound `claw-type` headers. Going through the edition
|
||||
// hook (instead of a hard-coded constant) keeps this site correct for
|
||||
// downstream editions that override MergeHeaders.
|
||||
func effectiveClawType() string {
|
||||
if h := edition.Get(); h != nil && h.MergeHeaders != nil {
|
||||
if v, ok := h.MergeHeaders(map[string]string{})["claw-type"]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return edition.DefaultOSSClawType
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newPluginCommand() *cobra.Command {
|
||||
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
|
||||
|
||||
pluginCmd.AddCommand(
|
||||
newPluginListCommand(),
|
||||
newPluginInstallCommand(),
|
||||
newPluginInfoCommand(),
|
||||
newPluginEnableCommand(),
|
||||
newPluginDisableCommand(),
|
||||
newPluginRemoveCommand(),
|
||||
newPluginValidateCommand(),
|
||||
newPluginCreateCommand(),
|
||||
newPluginDevCommand(),
|
||||
newPluginConfigCommand(),
|
||||
newPluginBuildCommand(),
|
||||
)
|
||||
|
||||
return pluginCmd
|
||||
}
|
||||
|
||||
func newPluginListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("列出已安装的插件"),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
plugins := loader.ListInstalled()
|
||||
|
||||
wantJSON, _ := cmd.Flags().GetBool("json")
|
||||
if wantJSON {
|
||||
return output.WriteJSON(cmd.OutOrStdout(), plugins)
|
||||
}
|
||||
|
||||
if len(plugins) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No plugins installed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "%-35s %-12s %-10s %-10s %s\n",
|
||||
"NAME", "VERSION", "TYPE", "STATUS", "DESCRIPTION")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 85))
|
||||
for _, p := range plugins {
|
||||
fmt.Fprintf(w, "%-35s %-12s %-10s %-10s %s\n",
|
||||
p.Name, p.Version, p.Type, statusStr(p.Enabled), p.Description)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginInstallCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: i18n.T("安装插件"),
|
||||
Example: ` dws plugin install --dir ./conference
|
||||
dws plugin install --git https://github.com/DingTalk-Real-AI/conference.git`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dirPath, _ := cmd.Flags().GetString("dir")
|
||||
gitURL, _ := cmd.Flags().GetString("git")
|
||||
|
||||
if dirPath == "" && gitURL == "" {
|
||||
return apperrors.NewValidation("specify install source: --dir <path> or --git <url>")
|
||||
}
|
||||
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
if gitURL != "" {
|
||||
p, err := loader.InstallFromGit(gitURL)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Installed %s (%s)\n", p.Manifest.Name, p.Manifest.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
p, err := loader.InstallFromDir(dirPath)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Installed %s (%s)\n", p.Manifest.Name, p.Manifest.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("dir", "", "Install from a local directory")
|
||||
cmd.Flags().String("git", "", "Install from a Git repository")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginInfoCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "info <name>",
|
||||
Short: i18n.T("查看插件详情"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
plugins := loader.ListInstalled()
|
||||
|
||||
for _, p := range plugins {
|
||||
if p.Name == name {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "Name: %s\n", p.Name)
|
||||
fmt.Fprintf(w, "Version: %s\n", p.Version)
|
||||
fmt.Fprintf(w, "Type: %s\n", p.Type)
|
||||
fmt.Fprintf(w, "Status: %s\n", statusStr(p.Enabled))
|
||||
fmt.Fprintf(w, "Path: %s\n", p.Path)
|
||||
if p.Description != "" {
|
||||
fmt.Fprintf(w, "Description: %s\n", p.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("plugin %q not found", name))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginEnableCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "enable <name>",
|
||||
Short: i18n.T("启用插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
if err := loader.SetEnabled(args[0], true); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s enabled.\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginDisableCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable <name>",
|
||||
Short: i18n.T("禁用插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
if err := loader.SetEnabled(args[0], false); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s disabled.\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginRemoveCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: i18n.T("卸载已安装的插件"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Stop stdio clients before removing to release file locks
|
||||
StopStdioClientsByPlugin(args[0])
|
||||
keepData, _ := cmd.Flags().GetBool("keep-data")
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
if err := loader.RemovePlugin(args[0], keepData); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Plugin %s removed.\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("keep-data", false, "Keep plugin data directory")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginValidateCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "validate <dir>",
|
||||
Short: i18n.T("校验 plugin.json"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := args[0]
|
||||
m, err := plugin.ParseManifest(dir + "/plugin.json")
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("parse failed: %v", err))
|
||||
}
|
||||
if err := m.Validate(RawVersion()); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Valid: %s (%s)\n", m.Name, m.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginCreateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: i18n.T("脚手架生成新插件目录"),
|
||||
Example: ` dws plugin create my-tool
|
||||
dws plugin create my-tool --description "My awesome tool"`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
pluginType := "user"
|
||||
|
||||
// Validate name format
|
||||
m := &plugin.Manifest{Name: name, Version: "0.1.0", Type: pluginType}
|
||||
if err := m.Validate(""); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid plugin name: %v", err))
|
||||
}
|
||||
|
||||
dir := filepath.Join(".", name)
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir))
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
dirs := []string{
|
||||
dir,
|
||||
filepath.Join(dir, "skills", name),
|
||||
filepath.Join(dir, "hooks"),
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Write plugin.json
|
||||
pluginJSON := fmt.Sprintf(`{
|
||||
"name": %q,
|
||||
"version": "0.1.0",
|
||||
"description": %q,
|
||||
"type": %q,
|
||||
"minCLIVersion": %q,
|
||||
"mcpServers": {
|
||||
%q: {
|
||||
"type": "stdio",
|
||||
"command": "${DWS_PLUGIN_ROOT}/bin/server",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"command": "echo 'TODO: replace with your build command, e.g.: bun build --compile src/server.ts --outfile bin/server'",
|
||||
"output": "bin/server"
|
||||
},
|
||||
"skills": "./skills/",
|
||||
"hooks": "./hooks/hooks.json"
|
||||
}
|
||||
`, name, desc, pluginType, RawVersion(), name)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err))
|
||||
}
|
||||
|
||||
// Write SKILL.md template
|
||||
skillMD := fmt.Sprintf(`---
|
||||
name: %s
|
||||
description: %s
|
||||
cli_version: ">=%s"
|
||||
---
|
||||
|
||||
# %s
|
||||
|
||||
## Intent Recognition
|
||||
|
||||
Use this skill when the user mentions:
|
||||
- TODO: add your intent keywords here
|
||||
|
||||
## Command Decision Tree
|
||||
|
||||
| User Intent | Command | Required Parameters |
|
||||
|-------------|---------|---------------------|
|
||||
| TODO | `+"`dws %s <sub-command>`"+` | `+"`--param`"+` |
|
||||
|
||||
## Parameter Rules
|
||||
|
||||
### TODO: parameter type
|
||||
- Format description
|
||||
- Conversion rules
|
||||
`, name, desc, RawVersion(), name, name)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err))
|
||||
}
|
||||
|
||||
// Write hooks.json template
|
||||
hooksJSON := `{
|
||||
"hooks": []
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err))
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "Created plugin scaffold at ./%s/\n", name)
|
||||
fmt.Fprintf(w, " %s/\n", name)
|
||||
fmt.Fprintf(w, " ├── plugin.json\n")
|
||||
fmt.Fprintf(w, " ├── skills/%s/SKILL.md\n", name)
|
||||
fmt.Fprintf(w, " └── hooks/hooks.json\n")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "Next steps:\n")
|
||||
fmt.Fprintf(w, " 1. Edit plugin.json to configure your MCP servers\n")
|
||||
fmt.Fprintf(w, " 2. Edit skills/%s/SKILL.md to describe your commands\n", name)
|
||||
fmt.Fprintf(w, " 3. Run: dws plugin validate ./%s\n", name)
|
||||
fmt.Fprintf(w, " 4. Run: dws plugin dev ./%s\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("description", "", "Plugin description")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginDevCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dev <dir>",
|
||||
Short: i18n.T("将本地目录注册为开发态插件"),
|
||||
Long: `Registers a plugin from a local source directory for development.
|
||||
The plugin is loaded directly from the source directory on next CLI invocation,
|
||||
without copying files to ~/.dws/plugins/. Use 'dws plugin dev --off <name>'
|
||||
to unregister.`,
|
||||
Example: ` dws plugin dev ./my-tool
|
||||
dws plugin dev --off my-tool`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
off, _ := cmd.Flags().GetBool("off")
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
if off {
|
||||
// Unregister dev plugin
|
||||
name := args[0]
|
||||
if err := loader.UnregisterDevPlugin(name); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Dev plugin %q unregistered.\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register dev plugin
|
||||
dir := args[0]
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||
}
|
||||
|
||||
// Validate the plugin first
|
||||
m, err := plugin.ParseManifest(filepath.Join(absDir, "plugin.json"))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid plugin at %s: %v", dir, err))
|
||||
}
|
||||
if err := m.Validate(RawVersion()); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
||||
}
|
||||
|
||||
if err := loader.RegisterDevPlugin(m.Name, absDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err))
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Dev plugin %q registered from %s\n", m.Name, absDir)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "It will be loaded on next dws invocation.\n")
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "To unregister: dws plugin dev --off %s\n", m.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("off", false, "Unregister a dev plugin")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginConfigCommand() *cobra.Command {
|
||||
configCmd := newPlaceholderParent("config", i18n.T("管理插件配置"))
|
||||
configCmd.AddCommand(
|
||||
newPluginConfigSetCommand(),
|
||||
newPluginConfigGetCommand(),
|
||||
newPluginConfigListCommand(),
|
||||
newPluginConfigUnsetCommand(),
|
||||
)
|
||||
return configCmd
|
||||
}
|
||||
|
||||
func newPluginConfigSetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "set <plugin-name> <key> <value>",
|
||||
Short: i18n.T("设置插件配置项"),
|
||||
Long: `Persistently set a configuration value for a plugin.
|
||||
The value is stored in ~/.dws/settings.json and automatically injected
|
||||
as an environment variable when the plugin is loaded.
|
||||
|
||||
Environment variables set by the user (e.g. via export) take precedence
|
||||
over values stored in settings.json.`,
|
||||
Example: ` dws plugin config set demo-devtool DASHSCOPE_API_KEY sk-xxx
|
||||
dws plugin config set my-plugin API_ENDPOINT https://api.example.com`,
|
||||
Args: cobra.ExactArgs(3),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName, key, value := args[0], args[1], args[2]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
// Validate that the plugin exists.
|
||||
plugins := loader.ListInstalled()
|
||||
found := false
|
||||
for _, p := range plugins {
|
||||
if p.Name == pluginName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return apperrors.NewValidation(fmt.Sprintf("plugin %q not found; use 'dws plugin list' to see installed plugins", pluginName))
|
||||
}
|
||||
|
||||
loader.SetPluginConfig(pluginName, key, value)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Config saved: %s.%s\n", pluginName, key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginConfigGetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <plugin-name> <key>",
|
||||
Short: i18n.T("读取插件配置项"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName, key := args[0], args[1]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
val, ok := loader.GetPluginConfig(pluginName, key)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("config key %q not set for plugin %q", key, pluginName))
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), val)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPluginConfigListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <plugin-name>",
|
||||
Short: i18n.T("列出插件所有配置项"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName := args[0]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
wantJSON, _ := cmd.Flags().GetBool("json")
|
||||
configs := loader.ListPluginConfig(pluginName)
|
||||
|
||||
// Also load the plugin manifest to show declared userConfig keys.
|
||||
declaredKeys := loadDeclaredUserConfig(loader, pluginName)
|
||||
|
||||
if wantJSON {
|
||||
result := make(map[string]any)
|
||||
for k, v := range configs {
|
||||
sensitive := false
|
||||
if ci, ok := declaredKeys[k]; ok {
|
||||
sensitive = ci.Sensitive
|
||||
}
|
||||
if sensitive {
|
||||
result[k] = maskSensitiveValue(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
// Include declared but unset keys.
|
||||
for k, ci := range declaredKeys {
|
||||
if _, set := configs[k]; !set {
|
||||
entry := map[string]any{
|
||||
"value": nil,
|
||||
"description": ci.Description,
|
||||
"required": ci.Default == "",
|
||||
}
|
||||
result[k] = entry
|
||||
}
|
||||
}
|
||||
return output.WriteJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"kind": "plugin_config",
|
||||
"plugin": pluginName,
|
||||
"config": result,
|
||||
})
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
if len(configs) == 0 && len(declaredKeys) == 0 {
|
||||
fmt.Fprintf(w, "No configuration for plugin %q.\n", pluginName)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "Configuration for %s:\n\n", pluginName)
|
||||
|
||||
// Show set values.
|
||||
for k, v := range configs {
|
||||
sensitive := false
|
||||
if ci, ok := declaredKeys[k]; ok {
|
||||
sensitive = ci.Sensitive
|
||||
}
|
||||
displayVal := v
|
||||
if sensitive {
|
||||
displayVal = maskSensitiveValue(v)
|
||||
}
|
||||
fmt.Fprintf(w, " %s = %s\n", k, displayVal)
|
||||
}
|
||||
|
||||
// Show declared but unset keys.
|
||||
for k, ci := range declaredKeys {
|
||||
if _, set := configs[k]; !set {
|
||||
desc := ""
|
||||
if ci.Description != "" {
|
||||
desc = " # " + ci.Description
|
||||
}
|
||||
fmt.Fprintf(w, " %s = (not set)%s\n", k, desc)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPluginConfigUnsetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "unset <plugin-name> <key>",
|
||||
Short: i18n.T("删除插件配置项"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pluginName, key := args[0], args[1]
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
|
||||
if !loader.UnsetPluginConfig(pluginName, key) {
|
||||
return apperrors.NewValidation(fmt.Sprintf("config key %q not set for plugin %q", key, pluginName))
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Config removed: %s.%s\n", pluginName, key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadDeclaredUserConfig loads the userConfig section from a plugin's manifest.
|
||||
func loadDeclaredUserConfig(loader *plugin.Loader, pluginName string) map[string]plugin.ConfigItem {
|
||||
plugins := loader.ListInstalled()
|
||||
for _, p := range plugins {
|
||||
if p.Name == pluginName {
|
||||
m, err := plugin.ParseManifest(filepath.Join(p.Path, "plugin.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return m.UserConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskSensitiveValue masks a sensitive value, showing only the first 4
|
||||
// and last 2 characters for values longer than 8 characters.
|
||||
func maskSensitiveValue(value string) string {
|
||||
if len(value) <= 8 {
|
||||
return strings.Repeat("*", len(value))
|
||||
}
|
||||
return value[:4] + strings.Repeat("*", len(value)-6) + value[len(value)-2:]
|
||||
}
|
||||
|
||||
func newPluginBuildCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "build <dir>",
|
||||
Short: i18n.T("将插件 stdio server 编译为原生二进制"),
|
||||
Long: `Runs the build command declared in plugin.json to compile the
|
||||
plugin's server into a single executable. This ensures plugin users
|
||||
don't need any language runtime (Node.js, Python, etc.) installed.
|
||||
|
||||
The build configuration is read from the "build" field in plugin.json:
|
||||
|
||||
{
|
||||
"build": {
|
||||
"command": "bun build --compile src/server.ts --outfile bin/server",
|
||||
"output": "bin/server"
|
||||
}
|
||||
}`,
|
||||
Example: ` dws plugin build ./my-plugin
|
||||
dws plugin build .`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := args[0]
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||
}
|
||||
|
||||
m, err := plugin.ParseManifest(filepath.Join(absDir, "plugin.json"))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid plugin at %s: %v", dir, err))
|
||||
}
|
||||
|
||||
if m.Build == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"plugin %q has no \"build\" field in plugin.json.\n"+
|
||||
"Add a build config, e.g.:\n\n"+
|
||||
" \"build\": {\n"+
|
||||
" \"command\": \"bun build --compile src/server.js --outfile bin/server\",\n"+
|
||||
" \"output\": \"bin/server\"\n"+
|
||||
" }", m.Name))
|
||||
}
|
||||
|
||||
if err := plugin.BuildPlugin(absDir); err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Build succeeded: %s\n", m.Build.Output)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func statusStr(enabled bool) string {
|
||||
if enabled {
|
||||
return "enabled"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// TestSharedCacheStoreConcurrentSaveTools verifies that a single *cache.Store
|
||||
// instance is safe for goroutines saving tool snapshots concurrently, as long
|
||||
// as each goroutine targets a distinct (partition, serverKey). This mirrors
|
||||
// the real plugin discovery path where each goroutine owns one plugin/server.
|
||||
//
|
||||
// Each call serializes to its own "<key>.json.tmp" file followed by a
|
||||
// rename(2) to the final path, so concurrent writers targeting distinct keys
|
||||
// never collide. The invariant asserted here: after N parallel writes, the
|
||||
// Store returns each written snapshot intact under LoadTools.
|
||||
func TestSharedCacheStoreConcurrentSaveTools(t *testing.T) {
|
||||
const (
|
||||
partition = "default/default"
|
||||
writers = 16
|
||||
)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", idx)
|
||||
if err := store.SaveTools(partition, key, cache.ToolsSnapshot{
|
||||
ServerKey: key,
|
||||
}); err != nil {
|
||||
t.Errorf("SaveTools(%s): %v", key, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
key := fmt.Sprintf("plugin:concurrent:%d", i)
|
||||
snapshot, _, err := store.LoadTools(partition, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools(%s): %v", key, err)
|
||||
}
|
||||
if snapshot.ServerKey != key {
|
||||
t.Errorf("LoadTools(%s) returned ServerKey %q", key, snapshot.ServerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServerConcurrent exercises the dynamicMu mutex on the
|
||||
// write path by spraying distinct server descriptors in parallel. Afterwards
|
||||
// every injected product ID must be resolvable — a missing entry would
|
||||
// indicate a lost write through an un-synchronized map update.
|
||||
func TestAppendDynamicServerConcurrent(t *testing.T) {
|
||||
dynamicMu.Lock()
|
||||
prev := struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = prev.endpoints
|
||||
dynamicProducts = prev.products
|
||||
dynamicAliases = prev.aliases
|
||||
dynamicToolEndpoints = prev.toolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
id := fmt.Sprintf("plugin-id-%d", idx)
|
||||
endpoint := fmt.Sprintf("https://example.test/%d", idx)
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
id := fmt.Sprintf("plugin-id-%d", i)
|
||||
if endpoint, ok := directRuntimeEndpoint(id, ""); !ok || endpoint == "" {
|
||||
t.Errorf("directRuntimeEndpoint(%q) = (%q, %v), want non-empty", id, endpoint, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterStdioClientConcurrent verifies the stdioMu-protected registry
|
||||
// survives concurrent writers — every registered client must be looked up
|
||||
// afterwards. Uses nil client pointers since LookupStdioClient only compares
|
||||
// keys, not values.
|
||||
func TestRegisterStdioClientConcurrent(t *testing.T) {
|
||||
stdioMu.Lock()
|
||||
prev := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = prev
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
RegisterStdioClient(fmt.Sprintf("plugin/%d", idx), nil)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
key := fmt.Sprintf("plugin/%d", i)
|
||||
if _, ok := LookupStdioClient(key); !ok {
|
||||
t.Errorf("LookupStdioClient(%q) missing after concurrent registration", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePluginColdTimeouts covers the three code paths of the env
|
||||
// parser: unset (defaults), valid duration (applied to all three slots),
|
||||
// and invalid duration (logged and ignored, defaults returned).
|
||||
func TestResolvePluginColdTimeouts(t *testing.T) {
|
||||
t.Run("defaults when env unset", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("httpNoAuth = %v, want 1s", got.httpNoAuth)
|
||||
}
|
||||
if got.httpAuth != 1500*time.Millisecond {
|
||||
t.Errorf("httpAuth = %v, want 1.5s", got.httpAuth)
|
||||
}
|
||||
if got.stdio != 2*time.Second {
|
||||
t.Errorf("stdio = %v, want 2s", got.stdio)
|
||||
}
|
||||
})
|
||||
t.Run("env override applies to all slots", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "3500ms")
|
||||
got := resolvePluginColdTimeouts()
|
||||
want := 3500 * time.Millisecond
|
||||
if got.httpNoAuth != want || got.httpAuth != want || got.stdio != want {
|
||||
t.Errorf("override not propagated: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("invalid env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "not-a-duration")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second || got.stdio != 2*time.Second {
|
||||
t.Errorf("invalid env should not override defaults: %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("non-positive env falls back to defaults", func(t *testing.T) {
|
||||
t.Setenv(cli.PluginColdTimeoutEnv, "0")
|
||||
got := resolvePluginColdTimeouts()
|
||||
if got.httpNoAuth != 1*time.Second {
|
||||
t.Errorf("zero duration should not override defaults, got %v", got.httpNoAuth)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
|
||||
// from its manifest. It supports two forms:
|
||||
//
|
||||
// 1. inline JSON object in manifest.MCPServers[key].CLI
|
||||
// 2. a relative file path (JSON string) pointing to an external overlay
|
||||
// file anchored at the plugin root (e.g. "overlay.json")
|
||||
//
|
||||
// When no CLI metadata is present, a minimal overlay keyed by the server
|
||||
// name is returned so callers can still build an identity descriptor.
|
||||
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.CLIOverlay {
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
srv, ok := p.Manifest.MCPServers[sc.Key]
|
||||
if !ok || len(srv.CLI) == 0 {
|
||||
return overlay
|
||||
}
|
||||
|
||||
cliData := srv.CLI
|
||||
// A JSON string is interpreted as a relative path to an external
|
||||
// overlay file (e.g. "overlay.json") anchored at the plugin root.
|
||||
if len(cliData) > 0 && cliData[0] == '"' {
|
||||
var cliPath string
|
||||
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
|
||||
absPath := filepath.Join(p.Root, cliPath)
|
||||
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
cliData = fileData
|
||||
} else {
|
||||
slog.Warn("plugin: failed to read CLI overlay file",
|
||||
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(cliData, &overlay); err != nil {
|
||||
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
}
|
||||
if overlay.ID == "" {
|
||||
overlay.ID = serverID
|
||||
}
|
||||
if overlay.Command == "" {
|
||||
overlay.Command = serverID
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
|
||||
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
|
||||
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
|
||||
// Returns nil if tools is empty.
|
||||
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
detailTools := make([]market.DetailTool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
schemaJSON := ""
|
||||
if tool.InputSchema != nil {
|
||||
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
|
||||
schemaJSON = string(data)
|
||||
}
|
||||
}
|
||||
detailTools = append(detailTools, market.DetailTool{
|
||||
ToolName: tool.Name,
|
||||
ToolTitle: tool.Title,
|
||||
ToolDesc: tool.Description,
|
||||
IsSensitive: tool.Sensitive,
|
||||
ToolRequest: schemaJSON,
|
||||
})
|
||||
}
|
||||
return map[string][]market.DetailTool{overlayID: detailTools}
|
||||
}
|
||||
|
||||
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
|
||||
// server using only its manifest + overlay.json — no subprocess required.
|
||||
//
|
||||
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
|
||||
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
|
||||
// registration (legacy path).
|
||||
//
|
||||
// When a warm tools cache exists for this server, its DetailTools are passed
|
||||
// to BuildDynamicCommands so flag types are enriched from the last successful
|
||||
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
|
||||
// only; the next startup after a successful refresh picks up the full schema.
|
||||
func registerStdioServerFromOverlay(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
runner executor.Runner,
|
||||
store *cache.Store,
|
||||
) ([]*cobra.Command, market.ServerDescriptor, bool) {
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
return nil, market.ServerDescriptor{}, false
|
||||
}
|
||||
|
||||
descriptor := market.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
// Warm-cache enrichment: if a prior successful discovery wrote a
|
||||
// non-empty tool list, use its schema to enrich flag types.
|
||||
var detailsByID map[string][]market.DetailTool
|
||||
if store != nil {
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
|
||||
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
|
||||
}
|
||||
}
|
||||
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from overlay",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"toolOverrides", len(overlay.ToolOverrides),
|
||||
"commands", len(cmds),
|
||||
"enriched", detailsByID != nil)
|
||||
|
||||
return cmds, descriptor, true
|
||||
}
|
||||
|
||||
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
|
||||
// subprocess and persists the result so the next startup can enrich
|
||||
// overlay-registered commands with typed flags. It never constructs cobra
|
||||
// commands; command registration has already happened synchronously from
|
||||
// the overlay before this function runs.
|
||||
//
|
||||
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
|
||||
// SaveTools entirely so a transient error cannot poison the warm cache
|
||||
// with a null-tools snapshot.
|
||||
func refreshStdioToolsCache(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
store *cache.Store,
|
||||
timeouts pluginColdTimeouts,
|
||||
) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
tools := discoverStdioTools(p, sc, timeouts)
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||||
return
|
||||
}
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
|
||||
ServerKey: cacheKey,
|
||||
Tools: tools,
|
||||
}); err != nil {
|
||||
slog.Warn("plugin: failed to persist stdio tools cache",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("plugin: stdio tools cache refreshed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
|
||||
}
|
||||
|
||||
// hasOverlayToolOverrides reports whether a stdio plugin server carries
|
||||
// enough CLI metadata to be registered via the overlay-first path. Used by
|
||||
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
|
||||
// buckets without doing the overlay parse twice.
|
||||
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
|
||||
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"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/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// withCleanStdioRegistry snapshots and restores the package-level stdio
|
||||
// client registry so tests that call RegisterStdioClient don't leak state
|
||||
// across cases.
|
||||
func withCleanStdioRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
stdioMu.Lock()
|
||||
prev := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
stdioMu.Lock()
|
||||
stdioClients = prev
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// newOverlayFixture constructs a plugin + stdio entry carrying an inline
|
||||
// CLIOverlay with the given tool-override map. The stdio client is created
|
||||
// but never started, since the overlay-first path does not require the
|
||||
// subprocess to be running for command registration.
|
||||
func newOverlayFixture(t *testing.T, pluginName, serverKey string, overlay market.CLIOverlay) (*plugin.Plugin, plugin.StdioServerClient) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(overlay)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal overlay: %v", err)
|
||||
}
|
||||
p := &plugin.Plugin{
|
||||
Manifest: plugin.Manifest{
|
||||
Name: pluginName,
|
||||
Version: "1.0.0",
|
||||
Description: pluginName + " plugin",
|
||||
MCPServers: map[string]*plugin.MCPServer{
|
||||
serverKey: {
|
||||
Type: "stdio",
|
||||
Command: "/usr/bin/true", // never executed by overlay-first path
|
||||
CLI: raw,
|
||||
},
|
||||
},
|
||||
},
|
||||
Root: t.TempDir(),
|
||||
}
|
||||
sc := plugin.StdioServerClient{
|
||||
Key: serverKey,
|
||||
Client: transport.NewStdioClient("/usr/bin/true", nil, nil),
|
||||
}
|
||||
return p, sc
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands verifies
|
||||
// the core promise of the overlay-first path: when overlay.json ships
|
||||
// ToolOverrides, commands appear immediately — no subprocess probe.
|
||||
func TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "conference-local",
|
||||
Command: "conference-local",
|
||||
Groups: map[string]market.CLIGroupDef{
|
||||
"meeting": {Description: "会议控制"},
|
||||
"member": {Description: "成员管理"},
|
||||
},
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_meeting": {CLIName: "create", Group: "meeting", Description: "Create a meeting"},
|
||||
"end_meeting": {CLIName: "end", Group: "meeting", Description: "End a meeting"},
|
||||
"mute_member": {CLIName: "mute", Group: "member", Description: "Mute a member"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, desc, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok {
|
||||
t.Fatal("registerStdioServerFromOverlay returned ok=false, want true")
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Fatal("registerStdioServerFromOverlay returned 0 commands, want >=1")
|
||||
}
|
||||
|
||||
var root *struct{ name, path string }
|
||||
_ = root
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "conference-local" {
|
||||
found = true
|
||||
// Groups must be attached as sub-commands.
|
||||
groups := map[string]bool{}
|
||||
for _, sub := range c.Commands() {
|
||||
groups[sub.Name()] = true
|
||||
}
|
||||
if !groups["meeting"] {
|
||||
t.Errorf("missing 'meeting' group sub-command, children = %v", groups)
|
||||
}
|
||||
if !groups["member"] {
|
||||
t.Errorf("missing 'member' group sub-command, children = %v", groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
names := []string{}
|
||||
for _, c := range cmds {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
t.Fatalf("missing top-level 'conference-local' command, got %v", names)
|
||||
}
|
||||
|
||||
// AppendDynamicServer registration: product ID should land in
|
||||
// DirectRuntimeProductIDs so hideNonDirectRuntimeCommands keeps it
|
||||
// visible even under a restrictive VisibleProducts hook.
|
||||
if !DirectRuntimeProductIDs()["conference-local"] {
|
||||
t.Error("DirectRuntimeProductIDs missing 'conference-local'")
|
||||
}
|
||||
|
||||
// RegisterStdioClient side-effect: the runtime must be able to look up
|
||||
// the StdioClient when the endpoint is invoked later.
|
||||
if _, ok := LookupStdioClient("conference-local/conference-local"); !ok {
|
||||
t.Error("LookupStdioClient missing conference-local/conference-local")
|
||||
}
|
||||
|
||||
if desc.Endpoint != StdioEndpoint("conference-local", "conference-local") {
|
||||
t.Errorf("descriptor.Endpoint = %q, want %q", desc.Endpoint, StdioEndpoint("conference-local", "conference-local"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags pre-populates the
|
||||
// tools cache with a schema-bearing DetailTool and asserts the resulting
|
||||
// leaf command picks up the typed flag derived from InputSchema.
|
||||
func TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "cache-plugin",
|
||||
Command: "cache-plugin",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"echo": {CLIName: "echo", Description: "Echo input"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "cache-plugin", "cache-plugin", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
ServerKey: cacheKey,
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Echo the input",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"message": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTools: %v", err)
|
||||
}
|
||||
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok || len(cmds) == 0 {
|
||||
t.Fatalf("overlay registration failed: ok=%v cmds=%d", ok, len(cmds))
|
||||
}
|
||||
|
||||
var echoLeaf *leafMatch
|
||||
for _, top := range cmds {
|
||||
if top.Name() != "cache-plugin" {
|
||||
continue
|
||||
}
|
||||
for _, sub := range top.Commands() {
|
||||
if sub.Name() == "echo" {
|
||||
echoLeaf = &leafMatch{name: sub.Name(), hasFlag: sub.Flags().Lookup("message") != nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
if echoLeaf == nil {
|
||||
t.Fatal("missing 'echo' leaf command under 'cache-plugin'")
|
||||
}
|
||||
if !echoLeaf.hasFlag {
|
||||
t.Error("warm-cache enrichment did not wire --message flag from InputSchema")
|
||||
}
|
||||
}
|
||||
|
||||
type leafMatch struct {
|
||||
name string
|
||||
hasFlag bool
|
||||
}
|
||||
|
||||
// TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse
|
||||
// asserts the fallback contract: when overlay.json is missing toolOverrides,
|
||||
// the overlay-first path declines so the caller can route the entry through
|
||||
// the legacy discovery-first registerStdioServer.
|
||||
func TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
// Overlay with no ToolOverrides (simulates a plugin that relies entirely
|
||||
// on runtime discovery for its tool list).
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "legacy-plugin",
|
||||
Command: "legacy-plugin",
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "legacy-plugin", "legacy-plugin", overlay)
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if ok {
|
||||
t.Errorf("registerStdioServerFromOverlay ok=true for empty toolOverrides; want false")
|
||||
}
|
||||
if cmds != nil {
|
||||
t.Errorf("cmds = %v, want nil", cmds)
|
||||
}
|
||||
if DirectRuntimeProductIDs()["legacy-plugin"] {
|
||||
t.Error("legacy-plugin must NOT be appended to dynamic registry in fallback case")
|
||||
}
|
||||
if _, found := LookupStdioClient("legacy-plugin/legacy-plugin"); found {
|
||||
t.Error("stdio client must NOT be registered in fallback case")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshStdioToolsCache_FailurePreservesCache guards against the
|
||||
// "negative cache poisoning" bug: if discovery fails (subprocess not ready,
|
||||
// timeout, empty tool list), the existing warm cache must remain intact so
|
||||
// the next startup still enriches flags from the last good snapshot.
|
||||
func TestRefreshStdioToolsCache_FailurePreservesCache(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
p, sc := newOverlayFixture(t, "refresh-plugin", "refresh-plugin", market.CLIOverlay{
|
||||
ID: "refresh-plugin",
|
||||
Command: "refresh-plugin",
|
||||
})
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
|
||||
goodSnapshot := cache.ToolsSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
ServerKey: cacheKey,
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{
|
||||
Name: "ping",
|
||||
Description: "Health check",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := store.SaveTools(config.DefaultPartition, cacheKey, goodSnapshot); err != nil {
|
||||
t.Fatalf("seed SaveTools: %v", err)
|
||||
}
|
||||
|
||||
// /usr/bin/true exits immediately, so Initialize + ListTools will fail
|
||||
// (no MCP handshake). discoverStdioTools returns nil → refresh must be
|
||||
// a no-op and must NOT overwrite the good cache with a null snapshot.
|
||||
refreshStdioToolsCache(p, sc, store, pluginColdTimeouts{stdio: 200 * time.Millisecond})
|
||||
|
||||
got, _, err := store.LoadTools(config.DefaultPartition, cacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTools after failed refresh: %v", err)
|
||||
}
|
||||
if len(got.Tools) != 1 || got.Tools[0].Name != "ping" {
|
||||
t.Errorf("warm cache was overwritten by failed refresh: %+v", got.Tools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery is an integration-style
|
||||
// test for the loadPlugins split decision: stdio plugins whose overlay ships
|
||||
// ToolOverrides must have their commands visible on the root immediately,
|
||||
// WITHOUT waiting on any discovery handshake. It drives the same sequence
|
||||
// loadPlugins uses (registerStdioServerFromOverlay → root.AddCommand →
|
||||
// hideNonDirectRuntimeCommands) and asserts the plugin command survives the
|
||||
// visibility filter even when no discovery has run.
|
||||
func TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
withCleanStdioRegistry(t)
|
||||
|
||||
// Simulate a wukong-like edition that declares a static VisibleProducts
|
||||
// whitelist NOT containing our plugin. This is the exact scenario where
|
||||
// the original bug surfaced.
|
||||
overrideVisibleProducts(t, []string{"calendar", "doc"})
|
||||
|
||||
overlay := market.CLIOverlay{
|
||||
ID: "conference-local",
|
||||
Command: "conference-local",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_meeting": {CLIName: "create", Description: "Create a meeting"},
|
||||
},
|
||||
}
|
||||
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
|
||||
|
||||
// No discovery runs — no cache seeded. This mirrors a cold-start where
|
||||
// the subprocess is unavailable (or just slow) yet the user expects
|
||||
// `dws --help` to still list the plugin.
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
|
||||
if !ok {
|
||||
t.Fatal("registerStdioServerFromOverlay returned ok=false")
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
// Also add a sibling command that is NOT a registered product so we can
|
||||
// prove the visibility filter still hides non-product commands.
|
||||
bogus := &cobra.Command{Use: "bogus-not-a-product"}
|
||||
root.AddCommand(bogus)
|
||||
for _, c := range cmds {
|
||||
root.AddCommand(c)
|
||||
}
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
var pluginCmd *cobra.Command
|
||||
for _, c := range root.Commands() {
|
||||
if c.Name() == "conference-local" {
|
||||
pluginCmd = c
|
||||
}
|
||||
}
|
||||
if pluginCmd == nil {
|
||||
t.Fatal("conference-local missing from root after overlay-first registration")
|
||||
}
|
||||
if pluginCmd.Hidden {
|
||||
t.Error("conference-local must stay visible (Hidden=false) after hideNonDirectRuntimeCommands")
|
||||
}
|
||||
if !bogus.Hidden {
|
||||
t.Error("bogus-not-a-product must be hidden by the visibility filter")
|
||||
}
|
||||
|
||||
services := visibleMCPRootCommands(root)
|
||||
if !containsCommand(services, "conference-local") {
|
||||
t.Errorf("visibleMCPRootCommands missing conference-local: %v", commandNames(services))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasOverlayToolOverrides exercises the split-decision helper used by
|
||||
// loadPlugins to route stdio entries to overlay-first vs. legacy buckets.
|
||||
func TestHasOverlayToolOverrides(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
overlay market.CLIOverlay
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty overlay",
|
||||
overlay: market.CLIOverlay{ID: "x", Command: "x"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "overlay with overrides",
|
||||
overlay: market.CLIOverlay{
|
||||
ID: "x",
|
||||
Command: "x",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"foo": {CLIName: "foo"},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, sc := newOverlayFixture(t, "x", "x", tc.overlay)
|
||||
got := hasOverlayToolOverrides(p, sc)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasOverlayToolOverrides = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
|
||||
// IPv4-forcing client used by the legacy registry / discovery path. The
|
||||
// custom Transport overrides DialContext to force IPv4 — without an
|
||||
// explicit Proxy field it would also drop env-var proxy support.
|
||||
//
|
||||
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
|
||||
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
|
||||
// tests that read proxy env early would make this flaky. Asserting that the
|
||||
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
|
||||
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
|
||||
// at process boot.
|
||||
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := ipv4HTTPClient(5 * time.Second)
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`}
|
||||
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
|
||||
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
|
||||
+798
-30
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
|
||||
@@ -106,6 +107,98 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
|
||||
// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
|
||||
// DiscoveryHeaders when they are set, instead of the default Market endpoint.
|
||||
// Kept deliberately generic (no edition-specific strings) — concrete values
|
||||
// belong to the overlay repo that installs the hooks, not to this open core.
|
||||
func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
var (
|
||||
editionHits atomic.Int32
|
||||
marketHits atomic.Int32
|
||||
gotHeaders atomic.Value // map[string]string
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/edition/apis":
|
||||
editionHits.Add(1)
|
||||
snapshot := map[string]string{
|
||||
"x-test-edition": r.Header.Get("x-test-edition"),
|
||||
"x-test-client": r.Header.Get("x-test-client"),
|
||||
}
|
||||
gotHeaders.Store(snapshot)
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 1},
|
||||
Servers: []market.ServerEnvelope{
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Edition Service",
|
||||
Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "testing",
|
||||
DiscoveryURL: srv.URL + "/cli/edition/apis",
|
||||
DiscoveryHeaders: func() map[string]string {
|
||||
return map[string]string{
|
||||
"x-test-edition": "custom",
|
||||
"x-test-client": "cli-refresh",
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmd := newCacheCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"refresh"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
if got := editionHits.Load(); got != 1 {
|
||||
t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
|
||||
}
|
||||
if got := marketHits.Load(); got != 0 {
|
||||
t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
|
||||
}
|
||||
|
||||
headers, _ := gotHeaders.Load().(map[string]string)
|
||||
if headers == nil {
|
||||
t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
|
||||
}
|
||||
if headers["x-test-edition"] != "custom" {
|
||||
t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
|
||||
}
|
||||
if headers["x-test-client"] != "cli-refresh" {
|
||||
t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ import (
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
|
||||
// patLikeError simulates an edition-specific PAT error that implements both
|
||||
// ExitCoder (exit code 4) and RawStderrError (raw JSON to stderr).
|
||||
type patLikeError struct{ raw string }
|
||||
|
||||
func (e *patLikeError) Error() string { return e.raw }
|
||||
func (e *patLikeError) ExitCode() int { return 4 }
|
||||
func (e *patLikeError) RawStderr() string { return e.raw }
|
||||
|
||||
func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -43,11 +51,11 @@ func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +73,11 @@ func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +105,11 @@ func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +180,8 @@ func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(version) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "\"version\"") {
|
||||
t.Fatalf("version output missing version key:\n%s", out.String())
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +221,8 @@ func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing
|
||||
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
|
||||
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
|
||||
}
|
||||
if !strings.Contains(out.String(), "\"version\"") {
|
||||
t.Fatalf("version output missing version key:\n%s", out.String())
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +263,11 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
if !strings.Contains(out.String(), "Discovered MCP Services:") {
|
||||
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
|
||||
}
|
||||
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("root help output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
@@ -342,3 +355,86 @@ func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Fatalf("nested short help output missing command title:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_writes_raw_JSON_to_stderr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJSON := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{}}`
|
||||
err := &patLikeError{raw: rawJSON}
|
||||
|
||||
root := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeErr := printExecutionError(root, &stdout, &stderr, err)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", writeErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty for RawStderrError", stdout.String())
|
||||
}
|
||||
got := strings.TrimSpace(stderr.String())
|
||||
if got != rawJSON {
|
||||
t.Fatalf("stderr = %q, want raw JSON %q", got, rawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_exit_code_is_4(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := &patLikeError{raw: `{"code":"PAT_MEDIUM_RISK_NO_PERMISSION"}`}
|
||||
exitCode := apperrors.ExitCode(err)
|
||||
if exitCode != 4 {
|
||||
t.Fatalf("apperrors.ExitCode(patLikeError) = %d, want 4", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionError_RawStderrError_takes_precedence_over_JSON_mode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJSON := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION"}`
|
||||
err := &patLikeError{raw: rawJSON}
|
||||
|
||||
root := NewRootCommand()
|
||||
_ = root.PersistentFlags().Set("format", "json")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
writeErr := printExecutionError(root, &stdout, &stderr, err)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", writeErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty — RawStderrError should bypass JSON mode", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "PAT_HIGH_RISK_NO_PERMISSION") {
|
||||
t.Fatalf("stderr = %q, want raw PAT JSON", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// simulateExecuteWithPanic mirrors the recovery pattern in Execute():
|
||||
// named return + defer recover → exitCode = 5 on panic.
|
||||
func simulateExecuteWithPanic(doPanic bool) (exitCode int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
exitCode = 5
|
||||
}
|
||||
}()
|
||||
if doPanic {
|
||||
panic("test panic")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestExecute_panic_recovery_returns_exit_5(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(true)
|
||||
if code != 5 {
|
||||
t.Fatalf("panic recovery exitCode = %d, want 5", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_no_panic_returns_0(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(false)
|
||||
if code != 0 {
|
||||
t.Fatalf("no-panic exitCode = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -13,6 +15,26 @@ func configureRootHelp(root *cobra.Command) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace the cobra-default English help command with a localized one so
|
||||
// that both its listing short (shown in `dws --help`) and its own
|
||||
// `dws help --help` long text follow the active locale.
|
||||
root.SetHelpCommand(&cobra.Command{
|
||||
Use: "help [command]",
|
||||
Short: i18n.T("查看任意命令的帮助信息"),
|
||||
Long: i18n.T("显示任意命令的帮助文案。\n" +
|
||||
"用法:dws help [命令路径] 查看完整说明。"),
|
||||
DisableAutoGenTag: true,
|
||||
Run: func(c *cobra.Command, args []string) {
|
||||
target, _, err := c.Root().Find(args)
|
||||
if target == nil || err != nil {
|
||||
c.Root().HelpFunc()(c.Root(), args)
|
||||
return
|
||||
}
|
||||
target.InitDefaultHelpFlag()
|
||||
_ = target.Help()
|
||||
},
|
||||
})
|
||||
|
||||
defaultHelpFunc := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != root {
|
||||
@@ -25,6 +47,7 @@ func configureRootHelp(root *cobra.Command) {
|
||||
|
||||
func renderRootHelp(root *cobra.Command) {
|
||||
services := visibleMCPRootCommands(root)
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
w := root.OutOrStdout()
|
||||
|
||||
if len(services) == 0 {
|
||||
@@ -44,8 +67,49 @@ func renderRootHelp(root *cobra.Command) {
|
||||
|
||||
_, _ = fmt.Fprintln(w, "Usage:")
|
||||
_, _ = fmt.Fprintln(w, " dws <service> [command] [flags]")
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, " dws <command> [flags]")
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service.`)
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, "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))
|
||||
}
|
||||
_ = 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.`)
|
||||
|
||||
// Render root.Long after the command list so agents see the upgrade
|
||||
// hint (or any other root-level guidance) after browsing all available
|
||||
// commands and concluding none of them fit. Cobra's default help template
|
||||
// would render Long automatically; the custom SetHelpFunc above replaces
|
||||
// it and dropped this, so we restore it explicitly here.
|
||||
if long := strings.TrimSpace(root.Long); long != "" {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, long)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 —
|
||||
// including plugins loaded via AppendDynamicServer — are never silently hidden
|
||||
// by a static VisibleProducts list.
|
||||
func resolveVisibleProducts() map[string]bool {
|
||||
allowed := map[string]bool{}
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
for _, p := range fn() {
|
||||
allowed[p] = true
|
||||
}
|
||||
}
|
||||
for id := range DirectRuntimeProductIDs() {
|
||||
allowed[id] = true
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
@@ -53,7 +117,7 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
allowed := DirectRuntimeProductIDs()
|
||||
allowed := resolveVisibleProducts()
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -70,3 +134,23 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func visibleUtilityRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
productCommands := resolveVisibleProducts()
|
||||
|
||||
commands := make([]*cobra.Command, 0)
|
||||
for _, cmd := range root.Commands() {
|
||||
if cmd == nil || cmd.Hidden {
|
||||
continue
|
||||
}
|
||||
if productCommands[cmd.Name()] {
|
||||
continue
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
+510
-33
@@ -15,23 +15,69 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "启用 MCP 响应内容安全扫描",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN_ENFORCE",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "内容安全扫描发现问题时阻断响应",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_RUNTIME_CONTENT_SCAN_REPORT",
|
||||
Category: configmeta.CategoryRuntime,
|
||||
Description: "在 JSON 输出中包含安全扫描报告",
|
||||
Example: "true",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_AGENT",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "业务 Agent 名称;仅用于 x-dingtalk-agent 请求头,与 claw-type/host-owned PAT 判定无关",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_TRACE_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-trace-id 头",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_SESSION_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-session-id 头",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DINGTALK_MESSAGE_ID",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "MCP 请求 x-dingtalk-message-id 头",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
runtimeContentScanEnv = "DWS_RUNTIME_CONTENT_SCAN"
|
||||
runtimeContentScanEnforceEnv = "DWS_RUNTIME_CONTENT_SCAN_ENFORCE"
|
||||
@@ -42,9 +88,42 @@ const (
|
||||
envDingtalkTraceID = "DINGTALK_TRACE_ID"
|
||||
envDingtalkSessionID = "DINGTALK_SESSION_ID"
|
||||
envDingtalkMessageID = "DINGTALK_MESSAGE_ID"
|
||||
|
||||
// Environment variables for third-party channel integration
|
||||
envDWSChannel = "DWS_CHANNEL"
|
||||
)
|
||||
|
||||
// hostOwnedPATDecisionOnce ensures the host-owned PAT decision is logged at
|
||||
// most once per CLI process. The log line is emitted at Debug level so
|
||||
// `--debug` (or `--verbose`) surfaces it on stderr; the file logger at
|
||||
// ~/.dws/logs/dws.log captures it unconditionally at DEBUG. It records
|
||||
// ONLY the derived booleans — never the env value, token, client-id or
|
||||
// flow-id — so logs remain safe to attach to issues.
|
||||
var hostOwnedPATDecisionOnce sync.Once
|
||||
|
||||
// logHostOwnedPATDecisionOnce emits the single-shot debug trace. It is
|
||||
// called lazily from the runtime Run path (which executes AFTER
|
||||
// PersistentPreRunE has applied --debug / --verbose via configureLogLevel)
|
||||
// so the line actually surfaces when the user asks for it.
|
||||
func logHostOwnedPATDecisionOnce() {
|
||||
hostOwnedPATDecisionOnce.Do(func() {
|
||||
slog.Debug("runtime.host_owned_pat",
|
||||
"hostOwned", authpkg.HostOwnsPATFlow(),
|
||||
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
|
||||
// Ensure DWS_CLIENT_ID env is populated from persisted config before
|
||||
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
|
||||
// where no env var has been inherited from a parent process.
|
||||
if os.Getenv("DWS_CLIENT_ID") == "" {
|
||||
if cid := authpkg.ClientID(); cid != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", cid)
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
@@ -74,6 +153,12 @@ type runtimeRunner struct {
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
// Emit the one-shot host-owned PAT decision log. Placed here (not in
|
||||
// the constructor) so it fires AFTER PersistentPreRunE has configured
|
||||
// slog level per --debug / --verbose. The Once guard makes repeat
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
@@ -87,23 +172,44 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
go getCachedRuntimeToken(ctx)
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
}
|
||||
|
||||
catalogStart := time.Now()
|
||||
catalog, err := r.loader.Load(ctx)
|
||||
RecordTiming(ctx, "catalog_load", time.Since(catalogStart))
|
||||
if err != nil {
|
||||
return executor.Result{}, err
|
||||
var degraded *cli.CatalogDegraded
|
||||
if !errors.As(err, °raded) {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
product, ok := catalog.FindProduct(invocation.CanonicalProduct)
|
||||
if !ok || strings.TrimSpace(product.Endpoint) == "" {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
|
||||
}
|
||||
if _, ok := product.FindTool(invocation.Tool); !ok {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
// Catalog knows the product but not the tool — this happens when the
|
||||
// catalog entry came from SupplementServers (endpoint-only, no tool
|
||||
// list). Trust directRuntimeEndpoint to re-resolve a working endpoint
|
||||
// for the tool. If that also misses, fall through to handleCatalogMiss
|
||||
// so stderr still carries the explicit not-resolved signal.
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
|
||||
}
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
@@ -113,11 +219,106 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Multi-server tool-name authority correction.
|
||||
//
|
||||
// When two envelope servers share the same cli.command (e.g. group-chat
|
||||
// and im both publish `dws chat ...`), the endpoints[cmd] map in
|
||||
// registerDynamicServer is the second-writer wins, and catalog FindProduct
|
||||
// may pick the wrong product's Endpoint for a tool whose real owner is
|
||||
// a different server. Cross-check the canonical tool→endpoint map: when
|
||||
// the per-tool endpoint exists and differs from the per-product endpoint
|
||||
// catalog returned, trust the tool-owner endpoint (the server that
|
||||
// actually declares this tool in its toolOverrides).
|
||||
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
|
||||
endpoint = toolEndpoint
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
|
||||
tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
//
|
||||
// Previously every catalog miss silently fell through to EchoRunner, which
|
||||
// returns an empty `executor.Result{Response: nil}`. The helper-invocation
|
||||
// adapter then converted that into `&edition.ToolResult{}`, whose `Content`
|
||||
// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no
|
||||
// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace.
|
||||
//
|
||||
// New contract:
|
||||
// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so
|
||||
// `--dry-run` still prints the planned payload without real execution.
|
||||
// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved")
|
||||
// with the offending product/tool attached. This fails fast to stderr and
|
||||
// makes missing envelopes / supplement gaps immediately visible.
|
||||
func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
|
||||
dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun)
|
||||
if dryRun {
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
|
||||
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
|
||||
if IsStdioEndpoint(endpoint) {
|
||||
return r.executeStdioInvocation(ctx, invocation)
|
||||
}
|
||||
|
||||
invokeStart := time.Now()
|
||||
execID := generateExecutionID()
|
||||
r.transport.ExecutionId = execID
|
||||
|
||||
// Lazy bind FileLogger: it may be nil at construction time because
|
||||
// configureLogLevel runs later in PersistentPreRunE.
|
||||
if r.transport.FileLogger == nil {
|
||||
r.transport.FileLogger = FileLoggerInstance()
|
||||
}
|
||||
|
||||
fl := r.transport.FileLogger
|
||||
|
||||
defer func() {
|
||||
var errCat, errReason string
|
||||
if retErr != nil {
|
||||
var typed *apperrors.Error
|
||||
if errors.As(retErr, &typed) {
|
||||
errCat = string(typed.Category)
|
||||
errReason = typed.Reason
|
||||
} else {
|
||||
errCat = "unknown"
|
||||
errReason = retErr.Error()
|
||||
}
|
||||
}
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
// If so, use the plugin's token instead of the default DingTalk OAuth token.
|
||||
// This allows third-party MCP servers (e.g. Bailian) to use their own API keys.
|
||||
pluginAuth, hasPluginAuth := LookupPluginAuth(invocation.CanonicalProduct)
|
||||
|
||||
authToken := ""
|
||||
if hasPluginAuth {
|
||||
authToken = pluginAuth.Token
|
||||
} else {
|
||||
authToken = r.resolveAuthToken(ctx)
|
||||
}
|
||||
|
||||
var timeoutSec int
|
||||
if r.globalFlags != nil {
|
||||
timeoutSec = r.globalFlags.Timeout
|
||||
}
|
||||
logging.LogCommandStart(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
return executor.Result{
|
||||
@@ -148,20 +349,115 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}, nil
|
||||
}
|
||||
|
||||
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
|
||||
if err != nil {
|
||||
// Fail-fast: reject unauthenticated requests before making network calls.
|
||||
// This provides a clear error message instead of cryptic HTTP 400 from MCP.
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"未登录,请先执行 dws auth login",
|
||||
apperrors.WithReason("not_authenticated"),
|
||||
apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
|
||||
apperrors.WithActions("dws auth login"),
|
||||
)
|
||||
}
|
||||
|
||||
var tc *transport.Client
|
||||
if hasPluginAuth {
|
||||
// Use plugin-level auth: inject the plugin's token and trust its domains.
|
||||
tc = r.transport.WithAuth(authToken, pluginAuth.ExtraHeaders)
|
||||
tc.TrustedDomains = pluginAuth.TrustedDomains
|
||||
} else {
|
||||
// Default path: use DingTalk OAuth token with identity headers.
|
||||
tc = r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
}
|
||||
|
||||
callCtx := ctx
|
||||
if r.globalFlags != nil && r.globalFlags.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
||||
captureRuntimeFailure(invocation, err, overrideErr)
|
||||
return executor.Result{}, overrideErr
|
||||
}
|
||||
}
|
||||
}
|
||||
// PAT scope error: offer human-readable output and retry after authorization
|
||||
if isPatScopeError(err) {
|
||||
scopeErr := extractPatScopeError(err)
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
// ---- Edition hook gets first dibs (preserves overlay PATError passthrough) ----
|
||||
if fn := edition.Get().ClassifyToolResult; fn != nil {
|
||||
if editionErr := fn(callResult.Content); editionErr != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(editionErr); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
return executor.Result{}, editionErr
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Structured PAT auth check (open-source fallback) ----
|
||||
if patCheck := apperrors.ClassifyPatAuthCheck(callResult.Content); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck // already retried once, don't loop
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
|
||||
if callResult.IsError {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
|
||||
|
||||
// ClassifyToolResult hook: let the overlay intercept known error
|
||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if hookErr := classify(callResult.Content); hookErr != nil {
|
||||
captureRuntimeFailure(invocation, hookErr, hookErr)
|
||||
return executor.Result{}, hookErr
|
||||
}
|
||||
}
|
||||
|
||||
mcpErr := apperrors.NewAPI(
|
||||
extractMCPErrorMessage(callResult),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
)
|
||||
// PAT scope error in business response: offer human-readable output and retry
|
||||
if isPatScopeError(mcpErr) {
|
||||
scopeErr := extractPatScopeError(mcpErr)
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||
return executor.Result{}, mcpErr
|
||||
}
|
||||
@@ -171,6 +467,18 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
|
||||
return executor.Result{}, apperrors.NewAPI(bizErr,
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
response := map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
@@ -182,37 +490,128 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
||||
}
|
||||
|
||||
// executeStdioInvocation dispatches a tool call through a local StdioClient
|
||||
// subprocess instead of the HTTP transport. This is used for plugin stdio
|
||||
// servers whose endpoints use the stdio:// scheme.
|
||||
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
if invocation.DryRun {
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"dry_run": true,
|
||||
"transport": "stdio",
|
||||
"request": executor.ToolCallRequest(invocation.Tool, invocation.Params),
|
||||
"note": "execution skipped by --dry-run",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
client, ok := LookupStdioClient(invocation.CanonicalProduct)
|
||||
if !ok {
|
||||
return executor.Result{}, apperrors.NewInternal(
|
||||
fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct))
|
||||
}
|
||||
|
||||
callCtx := ctx
|
||||
if r.globalFlags != nil && r.globalFlags.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("stdio call failed: %v", err),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("stdio_error"),
|
||||
)
|
||||
}
|
||||
|
||||
if callResult.IsError {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
extractMCPErrorMessage(callResult),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"transport": "stdio",
|
||||
"content": callResult.Content,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||
return token
|
||||
}
|
||||
if tp := edition.Get().TokenProvider; tp != nil {
|
||||
token, _ := tp(ctx, func() (string, error) {
|
||||
return resolveAccessTokenFromDir(ctx, defaultConfigDir())
|
||||
})
|
||||
return token
|
||||
}
|
||||
return getCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||
return token
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
token, tokenErr := provider.GetAccessToken(ctx)
|
||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
// If the error is a decryption failure (corrupted data), surface
|
||||
// it immediately instead of falling back to empty token.
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return ""
|
||||
}
|
||||
manager := authpkg.NewManager(configDir, nil)
|
||||
configureLegacyAuthManagerCompatibility(manager)
|
||||
if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
return ""
|
||||
// Use cached token to avoid repeated Keychain access (~70ms per call)
|
||||
return getCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
// Cached token state for process lifetime
|
||||
var (
|
||||
cachedRuntimeToken string
|
||||
cachedRuntimeTokenOnce sync.Once
|
||||
)
|
||||
|
||||
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
|
||||
// This avoids repeated Keychain access which takes ~70ms each time.
|
||||
func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cachedRuntimeTokenOnce.Do(func() {
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return
|
||||
}
|
||||
if token != "" {
|
||||
cachedRuntimeToken = token
|
||||
}
|
||||
})
|
||||
return cachedRuntimeToken
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
// all log entries (command_start, jsonrpc_request, command_end, etc.) belonging
|
||||
// to a single command invocation.
|
||||
func generateExecutionID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||
// This should be called after login/logout operations.
|
||||
func ResetRuntimeTokenCache() {
|
||||
cachedRuntimeTokenOnce = sync.Once{}
|
||||
cachedRuntimeToken = ""
|
||||
}
|
||||
|
||||
func newRuntimeContentScanner() safety.Scanner {
|
||||
@@ -246,6 +645,14 @@ func runtimeFlagEnabled(raw string, defaultValue bool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isAuthError(err error) bool {
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Category == apperrors.CategoryAuth
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func productEndpointOverride(productID string) (string, bool) {
|
||||
key := "DINGTALK_" + strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(productID), "-", "_")) + "_MCP_URL"
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
@@ -264,21 +671,58 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
|
||||
// Inject environment variable based headers for MCP gateway tracking
|
||||
// Inject environment variable based headers for MCP gateway tracking.
|
||||
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
|
||||
// x-dingtalk-agent header. It does NOT influence claw-type (which the
|
||||
// open-source edition pins to edition.DefaultOSSClawType via the
|
||||
// MergeHeaders hook below) and it does NOT influence the host-owned
|
||||
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
}
|
||||
for k, v := range envHeaders {
|
||||
if v != "" {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Inject third-party channel headers. DWS_CHANNEL is forwarded as the
|
||||
// upstream channelCode.
|
||||
if v := os.Getenv(envDWSChannel); v != "" {
|
||||
headers["x-dws-channel"] = v
|
||||
}
|
||||
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// detectBusinessError checks the MCP response content for DingTalk business
|
||||
// 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 {
|
||||
success, ok := content["success"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
b, ok := success.(bool)
|
||||
if !ok || b {
|
||||
return ""
|
||||
}
|
||||
if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return strings.TrimSpace(msg)
|
||||
}
|
||||
if code, ok := content["errorCode"].(string); ok && strings.TrimSpace(code) != "" {
|
||||
return "business error: code " + strings.TrimSpace(code)
|
||||
}
|
||||
return "business error: success=false"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -298,3 +742,36 @@ func extractMCPErrorMessage(result transport.ToolCallResult) string {
|
||||
}
|
||||
return "MCP tool returned an error response"
|
||||
}
|
||||
|
||||
// logBusinessError logs MCP tool errors and business errors to the file logger
|
||||
// so they can be diagnosed offline. These errors arrive as HTTP 200 responses
|
||||
// and would otherwise not be captured by transport-level logging.
|
||||
func logBusinessError(logger *slog.Logger, reason string, inv executor.Invocation, content map[string]any, diag apperrors.ServerDiagnostics) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
attrs := []any{
|
||||
"product", inv.CanonicalProduct,
|
||||
"tool", inv.Tool,
|
||||
"reason", reason,
|
||||
}
|
||||
if diag.TraceID != "" {
|
||||
attrs = append(attrs, "trace_id", diag.TraceID)
|
||||
}
|
||||
if diag.ServerErrorCode != "" {
|
||||
attrs = append(attrs, "server_error_code", diag.ServerErrorCode)
|
||||
}
|
||||
if diag.TechnicalDetail != "" {
|
||||
attrs = append(attrs, "technical_detail", diag.TechnicalDetail)
|
||||
}
|
||||
if msg, ok := content["error"].(string); ok {
|
||||
attrs = append(attrs, "error", msg)
|
||||
}
|
||||
if msg, ok := content["errorMsg"].(string); ok {
|
||||
attrs = append(attrs, "errorMsg", msg)
|
||||
}
|
||||
if msg, ok := content["message"].(string); ok {
|
||||
attrs = append(attrs, "message", msg)
|
||||
}
|
||||
logger.Warn("business_error", attrs...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// supplementOnlyCatalogLoader mimics the post-fix EnvironmentLoader: the
|
||||
// catalog has the product entry (materialised from SupplementServers) but
|
||||
// no tool list — the overlay owns the tool tree locally.
|
||||
type supplementOnlyCatalogLoader struct{}
|
||||
|
||||
func (supplementOnlyCatalogLoader) Load(_ context.Context) (ir.Catalog, error) {
|
||||
return ir.Catalog{
|
||||
Products: []ir.CanonicalProduct{
|
||||
{
|
||||
ID: "conference",
|
||||
ServerKey: "conference",
|
||||
Endpoint: "stdio://conference-catalog",
|
||||
Tools: nil,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resetDynamicServers(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := snapshotDynamicServers()
|
||||
t.Cleanup(func() { restoreDynamicServers(orig) })
|
||||
}
|
||||
|
||||
type dynamicServerSnapshot struct {
|
||||
endpoints map[string]string
|
||||
products map[string]bool
|
||||
aliases map[string]string
|
||||
toolEndpoints map[string]string
|
||||
}
|
||||
|
||||
func snapshotDynamicServers() dynamicServerSnapshot {
|
||||
dynamicMu.RLock()
|
||||
defer dynamicMu.RUnlock()
|
||||
return dynamicServerSnapshot{
|
||||
endpoints: cloneStringMap(dynamicEndpoints),
|
||||
products: cloneBoolMap(dynamicProducts),
|
||||
aliases: cloneStringMap(dynamicAliases),
|
||||
toolEndpoints: cloneStringMap(dynamicToolEndpoints),
|
||||
}
|
||||
}
|
||||
|
||||
func restoreDynamicServers(s dynamicServerSnapshot) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
dynamicEndpoints = s.endpoints
|
||||
dynamicProducts = s.products
|
||||
dynamicAliases = s.aliases
|
||||
dynamicToolEndpoints = s.toolEndpoints
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBoolMap(in map[string]bool) map[string]bool {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime pins the runner's
|
||||
// bridge between the catalog path (where a product entry can come from
|
||||
// SupplementServers with no tool list) and the direct-runtime path (which
|
||||
// carries the authoritative per-tool endpoint map). When the catalog knows
|
||||
// the product but not the tool, the runner should not fail-fast with
|
||||
// endpoint_not_resolved — it should consult dynamicEndpoints one more time
|
||||
// and proceed if an endpoint is registered.
|
||||
//
|
||||
// This is the narrow recovery path that keeps hardcoded overlay commands
|
||||
// working under a gray-released envelope: the supplement-materialised
|
||||
// catalog entry has endpoint+no tools, and SetDynamicServers holds the
|
||||
// operational endpoint indexed by product / command.
|
||||
func TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime(t *testing.T) {
|
||||
resetDynamicServers(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Key: "conference",
|
||||
DisplayName: "会议",
|
||||
Endpoint: "stdio://conference-fake",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "conference",
|
||||
Command: "conference",
|
||||
},
|
||||
Source: "edition_supplement",
|
||||
},
|
||||
})
|
||||
|
||||
runner := &runtimeRunner{
|
||||
loader: supplementOnlyCatalogLoader{},
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: executor.EchoRunner{},
|
||||
}
|
||||
|
||||
// Kind = api_invocation forces the code to skip the Run() opening
|
||||
// direct-runtime attempt and go through the catalog path instead, so
|
||||
// the tool-miss recovery branch we're testing actually runs.
|
||||
inv := executor.Invocation{
|
||||
Kind: "api_invocation",
|
||||
CanonicalProduct: "conference",
|
||||
Tool: "create_meeting_reservation",
|
||||
CanonicalPath: "conference.create_meeting_reservation",
|
||||
DryRun: true,
|
||||
Params: map[string]any{},
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), inv)
|
||||
if err != nil {
|
||||
t.Fatalf("runner.Run returned error, want tool-miss fallback success: %v", err)
|
||||
}
|
||||
if result.Response == nil {
|
||||
t.Fatalf("expected non-nil Response on dry-run")
|
||||
}
|
||||
if got, _ := result.Response["dry_run"].(bool); !got {
|
||||
t.Fatalf("expected dry_run=true in Response, got %v", result.Response)
|
||||
}
|
||||
if got, _ := result.Response["transport"].(string); got != "stdio" {
|
||||
t.Fatalf("expected transport=stdio in Response (proof we hit stdio://conference-fake), got %v", result.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed is the inverse
|
||||
// guard: when both the catalog tool list and dynamicEndpoints have no
|
||||
// record for the requested tool, the runner must still surface
|
||||
// endpoint_not_resolved instead of silently producing empty output.
|
||||
func TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed(t *testing.T) {
|
||||
resetDynamicServers(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{}) // intentionally empty
|
||||
|
||||
runner := &runtimeRunner{
|
||||
loader: supplementOnlyCatalogLoader{},
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: executor.EchoRunner{},
|
||||
}
|
||||
|
||||
inv := executor.Invocation{
|
||||
Kind: "api_invocation",
|
||||
CanonicalProduct: "conference",
|
||||
Tool: "nonexistent_tool",
|
||||
CanonicalPath: "conference.nonexistent_tool",
|
||||
Params: map[string]any{},
|
||||
}
|
||||
|
||||
_, err := runner.Run(context.Background(), inv)
|
||||
if err == nil {
|
||||
t.Fatalf("expected endpoint_not_resolved error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "endpoint not resolved") {
|
||||
t.Fatalf("expected endpoint_not_resolved error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nonexistent_tool") {
|
||||
t.Fatalf("error should name the missing tool; got %v", err)
|
||||
}
|
||||
}
|
||||
+428
-32
@@ -17,6 +17,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -24,13 +26,74 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
|
||||
func setupRuntimeCommandTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
// Isolate keychain storage so concurrent test packages can't leak a
|
||||
// real auth token into runtime tests via the shared on-disk keychain
|
||||
// location. We deliberately do NOT reset the process-wide token
|
||||
// cache here: getCachedRuntimeToken uses sync.Once and is read by
|
||||
// detached preload goroutines spawned per Run(); replacing the Once
|
||||
// races with those still-running goroutines.
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
|
||||
discoverySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(contactDiscoveryResponse())
|
||||
}))
|
||||
t.Cleanup(func() { discoverySrv.Close() })
|
||||
SetDiscoveryBaseURL(discoverySrv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
}
|
||||
|
||||
func contactDiscoveryResponse() map[string]any {
|
||||
return map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": "Contact",
|
||||
"description": "通讯录",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/contact/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active", "isLatest": true,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": map[string]any{
|
||||
"id": "contact",
|
||||
"command": "contact",
|
||||
"groups": map[string]any{
|
||||
"user": map[string]any{
|
||||
"description": "用户管理",
|
||||
},
|
||||
},
|
||||
"toolOverrides": map[string]any{
|
||||
"get_current_user_profile": map[string]any{
|
||||
"cliName": "get-self",
|
||||
"group": "user",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerIncludesContentScanReportWhenEnabled(t *testing.T) {
|
||||
@@ -45,7 +108,7 @@ func TestRuntimeRunnerIncludesContentScanReportWhenEnabled(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -90,7 +153,7 @@ func TestRuntimeRunnerBlocksUnsafeContentWhenEnforced(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -121,7 +184,7 @@ func TestCanonicalCommandUsesRuntimeRunnerWhenEnabled(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes"})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes", "--token", "test-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -255,7 +318,238 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "ALIDOC",
|
||||
"extension": "axls",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called for axls")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "axls-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want axls rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "extension=axls") {
|
||||
t.Fatalf("executeInvocation() error = %v, want extension=axls guidance", err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error category = %q, want validation", typed.Category)
|
||||
}
|
||||
if typed.Reason != "unsupported_alidoc_extension" {
|
||||
t.Fatalf("error reason = %q, want unsupported_alidoc_extension", typed.Reason)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightAllowsNonAXLSDownload(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "DRIVE",
|
||||
"extension": "xlsx",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"resourceUrl": []any{"https://example.invalid/file.xlsx"},
|
||||
}, false)
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "xlsx-node"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeInvocation() error = %v", err)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool+","+docDownloadFileTool {
|
||||
t.Fatalf("tool calls = %q, want preflight then download", got)
|
||||
}
|
||||
content, ok := result.Response["content"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("response.content = %#v, want map", result.Response["content"])
|
||||
}
|
||||
if _, ok := content["resourceUrl"]; !ok {
|
||||
t.Fatalf("response.content.resourceUrl missing: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightPATAuthorizationUsesExistingHandler(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
originalOpenBrowser := openBrowserFunc
|
||||
var openedURI string
|
||||
openBrowserFunc = func(uri string) error {
|
||||
openedURI = uri
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
|
||||
|
||||
const authURI = "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-1%26userCode%3DCODE#/personalAuthorization?flowId=flow-1&userCode=CODE"
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"flowId": "flow-1",
|
||||
"uri": authURI,
|
||||
"clientId": "client-1",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called before preflight PAT authorization")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
runner.globalFlags.Format = "json"
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "pat-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want PAT error")
|
||||
}
|
||||
var patErr *apperrors.PATError
|
||||
if !errors.As(err, &patErr) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.PATError", err)
|
||||
}
|
||||
if openedURI != authURI {
|
||||
t.Fatalf("opened URI = %q, want %q", openedURI, authURI)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s before PAT authorization", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
|
||||
// a valid token are rejected with a clear error before making any network call.
|
||||
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
server := mockmcp.DefaultServer()
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
// No --token flag, should be rejected
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want authentication error")
|
||||
}
|
||||
|
||||
// Verify we get a clear auth error, not a cryptic HTTP 400
|
||||
errMsg := err.Error()
|
||||
if !strings.Contains(errMsg, "未登录") {
|
||||
t.Fatalf("Execute() error = %v, want error containing '未登录'", err)
|
||||
}
|
||||
if !strings.Contains(errMsg, "auth login") {
|
||||
t.Fatalf("Execute() error = %v, want error containing 'auth login'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunnerErrorsForUnavailableProduct pins down the post-fix
|
||||
// (fix-wukong-discovery-missing-servers Phase 3) behaviour: when the catalog
|
||||
// does not carry the requested product (here `contact` against a
|
||||
// doc-only fixture), `runtimeRunner.Run` must surface an explicit
|
||||
// `endpoint_not_resolved` error instead of silently echoing an empty result
|
||||
// back to the user — the latter is what historically produced `{"Content":
|
||||
// null}` output on `dws doc list`.
|
||||
//
|
||||
// Dry-run / compat semantics continue to route through EchoRunner and are
|
||||
// covered by other tests in this file (TestRuntime*Dry*, TestCompat*).
|
||||
func TestRuntimeRunnerErrorsForUnavailableProduct(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
server := mockmcp.DefaultServer()
|
||||
defer server.Close()
|
||||
@@ -268,33 +562,16 @@ func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"-f", "json", "contact", "user", "get-self"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want endpoint_not_resolved error\noutput:\n%s", out.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Invocation struct {
|
||||
Implemented bool `json:"implemented"`
|
||||
CanonicalProduct string `json:"canonical_product"`
|
||||
Tool string `json:"tool"`
|
||||
} `json:"invocation"`
|
||||
Response map[string]any `json:"response"`
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "endpoint not resolved") {
|
||||
t.Fatalf("error message = %q, want it to contain \"endpoint not resolved\"", msg)
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
if payload.Invocation.Implemented {
|
||||
t.Fatalf("implemented = true, want false for fallback")
|
||||
}
|
||||
if payload.Invocation.CanonicalProduct != "contact" {
|
||||
t.Fatalf("canonical_product = %q, want contact", payload.Invocation.CanonicalProduct)
|
||||
}
|
||||
if payload.Invocation.Tool != "get_current_user_profile" {
|
||||
t.Fatalf("tool = %q, want get_current_user_profile", payload.Invocation.Tool)
|
||||
}
|
||||
if payload.Response != nil {
|
||||
t.Fatalf("response = %#v, want nil for echo fallback", payload.Response)
|
||||
if !strings.Contains(msg, "contact") {
|
||||
t.Fatalf("error message = %q, want it to mention product \"contact\"", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +692,7 @@ func TestCanonicalSensitiveToolAcceptsInteractiveConfirmation(t *testing.T) {
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetIn(strings.NewReader("yes\n"))
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--token", "test-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -465,7 +742,7 @@ func TestRuntimeRunnerUsesProductEndpointOverride(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
@@ -565,6 +842,125 @@ func contentScanServer() *mockmcp.Server {
|
||||
return mockmcp.MustNewServer(fixture)
|
||||
}
|
||||
|
||||
func runtimeRunnerForHTTPTest(server *httptest.Server) *runtimeRunner {
|
||||
client := transport.NewClient(server.Client())
|
||||
client.Stderr = &bytes.Buffer{}
|
||||
return &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "test-token", Timeout: 30},
|
||||
}
|
||||
}
|
||||
|
||||
func jsonRPCToolName(req map[string]any) string {
|
||||
params, _ := req["params"].(map[string]any)
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
name, _ := params["name"].(string)
|
||||
return name
|
||||
}
|
||||
|
||||
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
|
||||
t.Helper()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"content": content,
|
||||
"isError": isError,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestClassifyToolResultHookPreemptsBusinessError(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
method, _ := req["method"].(string)
|
||||
switch method {
|
||||
case "initialize":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
|
||||
"serverInfo": map[string]any{"name": "doc", "version": "1.0.0"},
|
||||
},
|
||||
})
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "tools/list":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"tools": []map[string]any{{
|
||||
"name": "search_documents",
|
||||
"title": "Search",
|
||||
"description": "Search documents",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
}},
|
||||
},
|
||||
})
|
||||
case "tools/call":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_LOW_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{"requiredScopes": []any{}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hookCalled := false
|
||||
sentinelMsg := "hook-intercepted-PAT"
|
||||
edition.Override(&edition.Hooks{
|
||||
ClassifyToolResult: func(content map[string]any) error {
|
||||
if code, ok := content["code"].(string); ok && strings.Contains(code, "PAT") {
|
||||
hookCalled = true
|
||||
return fmt.Errorf("%s", sentinelMsg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want hook sentinel error")
|
||||
}
|
||||
if !hookCalled {
|
||||
t.Fatal("ClassifyToolResult hook was not called")
|
||||
}
|
||||
if !strings.Contains(err.Error(), sentinelMsg) {
|
||||
t.Fatalf("error = %q, want hook sentinel %q (not generic business error)", err.Error(), sentinelMsg)
|
||||
}
|
||||
if strings.Contains(err.Error(), "business_error") || strings.Contains(err.Error(), "mcp_tool_error") {
|
||||
t.Fatalf("error = %q, should NOT contain generic framework error category", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerReturnsErrorWhenMCPIsErrorTrue(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
@@ -628,7 +1024,7 @@ func TestRuntimeRunnerReturnsErrorWhenMCPIsErrorTrue(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
|
||||
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
// 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 (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_SKILL_API_HOST",
|
||||
Category: configmeta.CategoryNetwork,
|
||||
Description: "覆盖 Skill API 地址",
|
||||
DefaultValue: "https://mcp.dingtalk.com",
|
||||
Example: "https://custom-mcp.example.com",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// legacySkillAPIHost is the legacy skill market host used by the old cli.
|
||||
legacySkillAPIHost = "https://mcp.dingtalk.com"
|
||||
// skillDownloadEndpoint is the API endpoint for downloading skills.
|
||||
skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
|
||||
// skillDownloadTimeout is the timeout for skill download operations.
|
||||
skillDownloadTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// downloadSkillResponse represents the API response for skill download.
|
||||
type downloadSkillResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result *downloadSkillResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// downloadSkillResult contains the download URL and file name.
|
||||
type downloadSkillResult struct {
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
// findSkillsResponse represents the legacy skill search API response.
|
||||
type findSkillsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
Result []CliSkillDTO `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// CliSkillDTO mirrors the old cli response payload for `skill search`.
|
||||
type CliSkillDTO struct {
|
||||
SkillID string `json:"skillId"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// agentSkillPaths maps target names to their relative skill installation paths.
|
||||
// These paths are relative to the user's home directory.
|
||||
var agentSkillPaths = map[string]string{
|
||||
"qoder": ".qoder/skills",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
}
|
||||
|
||||
// supportedTargets returns a comma-separated list of supported targets.
|
||||
func supportedTargets() string {
|
||||
targets := make([]string, 0, len(agentSkillPaths)+1)
|
||||
for target := range agentSkillPaths {
|
||||
targets = append(targets, target)
|
||||
}
|
||||
targets = append(targets, ".")
|
||||
return strings.Join(targets, ", ")
|
||||
}
|
||||
|
||||
func buildSkillCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "skill",
|
||||
Short: "技能管理",
|
||||
Long: "管理钉钉技能市场的技能。支持搜索、下载与安装到指定 Agent 目录。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newSkillInstallCommand(),
|
||||
newSkillGetCommand(),
|
||||
newSkillSearchCommand(),
|
||||
newSkillFindHintCommand(),
|
||||
newSkillAddHintCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillGetCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "获取技能压缩文件",
|
||||
Long: "从服务端下载技能包到本地临时目录。命令执行成功后会输出临时目录路径,供调用方使用。",
|
||||
Example: " dws skill get --skill-id <skillId>",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillGet,
|
||||
}
|
||||
cmd.Flags().String("skill-id", "", "技能 ID(必填)")
|
||||
_ = cmd.MarkFlagRequired("skill-id")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillSearchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "从钉钉技能市场搜索技能",
|
||||
Long: "从钉钉技能市场搜索技能,根据关键词返回匹配的技能列表。",
|
||||
Example: " dws skill search --query 关键词",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillFind,
|
||||
}
|
||||
cmd.Flags().String("query", "", "搜索关键词(必填)")
|
||||
_ = cmd.MarkFlagRequired("query")
|
||||
cmd.Flags().String("scopes", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillFindHintCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "find",
|
||||
Short: "兼容旧用法,提示使用 skill search",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill search --query <关键词>")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSkillInstallCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install <skillId> <target>",
|
||||
Short: "下载并安装技能到指定目录",
|
||||
Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
|
||||
|
||||
参数:
|
||||
skillId 技能 ID(必填),可从钉钉技能市场获取
|
||||
target 安装目标(必填),支持: %s
|
||||
|
||||
安装路径:
|
||||
qoder -> ~/.qoder/skills/
|
||||
claude -> ~/.claude/skills/
|
||||
cursor -> ~/.cursor/skills/
|
||||
codex -> ~/.codex/skills/
|
||||
opencode -> ~/.config/opencode/skills/
|
||||
. -> 当前目录
|
||||
|
||||
示例:
|
||||
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
|
||||
dws skill install skill-123 claude # 安装到 ~/.claude/skills/
|
||||
dws skill install skill-123 . # 安装到当前目录`, supportedTargets()),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillAdd,
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillAddHintCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "兼容旧用法,提示使用 skill install",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill install <skillId> <target>")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
skillID, _ := cmd.Flags().GetString("skill-id")
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID)))
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...")
|
||||
|
||||
tmpDir, err := downloadSkillToTmpDir(cmd.Context(), apiURL, accessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), tmpDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
keyword, _ := cmd.Flags().GetString("query")
|
||||
scopes, _ := cmd.Flags().GetString("scopes")
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/cli/find-skills?keyword=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(keyword)))
|
||||
if scopes != "" {
|
||||
apiURL += "&scopes=" + url.QueryEscape(scopes)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return parseLegacySkillAPIError(resp)
|
||||
}
|
||||
|
||||
var result findSkillsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to parse search response: %v", err))
|
||||
}
|
||||
if !result.Success {
|
||||
errMsg := strings.TrimSpace(result.ErrorMsg)
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(result.ErrorCode)
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown error"
|
||||
}
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %s", errMsg))
|
||||
}
|
||||
|
||||
if len(result.Result) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "未找到匹配的技能")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, skill := range result.Result {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "SkillID: %s\n", skill.SkillID)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", skill.Name)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Desc: %s\n", skill.Desc)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "---")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillAdd(cmd *cobra.Command, args []string) error {
|
||||
skillID := strings.TrimSpace(args[0])
|
||||
target := strings.TrimSpace(args[1])
|
||||
|
||||
if skillID == "" {
|
||||
return apperrors.NewValidation("skillId is required")
|
||||
}
|
||||
|
||||
// Resolve target path
|
||||
destPath, err := resolveSkillTargetPath(target)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
||||
}
|
||||
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
// Step 1: Get download URL from API
|
||||
fmt.Fprintf(w, "正在获取技能信息...\n")
|
||||
downloadResp, err := fetchSkillDownloadInfo(ctx, accessToken, skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !downloadResp.Success {
|
||||
errMsg := downloadResp.ErrorMsg
|
||||
if errMsg == "" {
|
||||
errMsg = downloadResp.ErrorCode
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown error"
|
||||
}
|
||||
return apperrors.NewAPI(fmt.Sprintf("failed to get skill download info: %s", errMsg),
|
||||
apperrors.WithReason(downloadResp.ErrorCode))
|
||||
}
|
||||
|
||||
if downloadResp.Result == nil || downloadResp.Result.DownloadURL == "" {
|
||||
return apperrors.NewAPI("skill download URL not found in response")
|
||||
}
|
||||
|
||||
// Step 2: Download the skill zip file
|
||||
fmt.Fprintf(w, "正在下载技能...\n")
|
||||
tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupTempFile(tempZipPath)
|
||||
|
||||
// Step 3: Extract zip to destination
|
||||
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
|
||||
if err := extractSkillZip(tempZipPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n[OK] 技能安装成功!\n")
|
||||
fmt.Fprintf(w, "安装路径: %s\n", destPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSkillAccessToken() (string, error) {
|
||||
configDir := defaultConfigDir()
|
||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
||||
return "", skillAuthError()
|
||||
}
|
||||
return tokenData.AccessToken, nil
|
||||
}
|
||||
|
||||
func skillAuthError() error {
|
||||
if edition.Get().IsEmbedded {
|
||||
return apperrors.NewAuth("认证信息已失效",
|
||||
apperrors.WithReason("not_authenticated"),
|
||||
apperrors.WithHint("请先完成钉钉账号登录后重试"))
|
||||
}
|
||||
return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first",
|
||||
apperrors.WithHint("请先执行 'dws auth login' 登录"),
|
||||
apperrors.WithActions("dws auth login"))
|
||||
}
|
||||
|
||||
func skillAPIHost() string {
|
||||
if override := strings.TrimSpace(os.Getenv("DWS_SKILL_API_HOST")); override != "" {
|
||||
return strings.TrimRight(override, "/")
|
||||
}
|
||||
return legacySkillAPIHost
|
||||
}
|
||||
|
||||
// resolveSkillTargetPath resolves the target argument to an absolute path.
|
||||
func resolveSkillTargetPath(target string) (string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("target is required")
|
||||
}
|
||||
|
||||
// Special case: current directory
|
||||
if target == "." {
|
||||
return os.Getwd()
|
||||
}
|
||||
|
||||
// Look up predefined agent paths
|
||||
relPath, ok := agentSkillPaths[strings.ToLower(target)]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unsupported target")
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(homeDir, relPath), nil
|
||||
}
|
||||
|
||||
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
|
||||
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
|
||||
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
|
||||
apperrors.WithRetryable(true))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, skillAuthError()
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("download API returned HTTP %d", resp.StatusCode),
|
||||
apperrors.WithRetryable(resp.StatusCode >= 500))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
|
||||
if err != nil {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("failed to read response: %v", err))
|
||||
}
|
||||
|
||||
var result downloadSkillResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("failed to parse response: %v", err))
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
|
||||
client := &http.Client{Timeout: skillDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", parseLegacySkillAPIError(resp)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "dws-skill-*")
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err))
|
||||
}
|
||||
|
||||
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
||||
destPath := filepath.Join(tmpDir, filename)
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||||
}
|
||||
return tmpDir, nil
|
||||
}
|
||||
|
||||
func filenameFromDisposition(cd string) string {
|
||||
if cd != "" {
|
||||
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
||||
if name := strings.TrimSpace(params["filename"]); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return "skill.zip"
|
||||
}
|
||||
|
||||
func parseLegacySkillAPIError(resp *http.Response) error {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return skillAuthError()
|
||||
case http.StatusBadRequest:
|
||||
return apperrors.NewValidation("request parameters are invalid")
|
||||
case http.StatusNotFound:
|
||||
return apperrors.NewValidation("skill does not exist or corresponding file was not found")
|
||||
default:
|
||||
return apperrors.NewAPI(fmt.Sprintf("skill API returned HTTP %d", resp.StatusCode),
|
||||
apperrors.WithRetryable(resp.StatusCode >= 500))
|
||||
}
|
||||
}
|
||||
|
||||
// downloadSkillFile downloads the skill zip file to a temporary location.
|
||||
func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create download request: %v", err))
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: skillDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
|
||||
apperrors.WithRetryable(true))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("download returned HTTP %d", resp.StatusCode),
|
||||
apperrors.WithRetryable(resp.StatusCode >= 500))
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
if fileName == "" {
|
||||
fileName = "skill.zip"
|
||||
}
|
||||
tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
|
||||
// Copy response body to temp file
|
||||
_, err = io.Copy(tempFile, resp.Body)
|
||||
closeErr := tempFile.Close()
|
||||
if err != nil {
|
||||
os.Remove(tempPath)
|
||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(tempPath)
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
|
||||
}
|
||||
|
||||
return tempPath, nil
|
||||
}
|
||||
|
||||
// extractSkillZip extracts a zip file to the destination directory.
|
||||
func extractSkillZip(zipPath, destDir string) error {
|
||||
// Ensure destination directory exists
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
|
||||
}
|
||||
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to open zip file: %v", err))
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
if err := extractZipFile(file, destDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractZipFile extracts a single file from the zip archive.
|
||||
func extractZipFile(file *zip.File, destDir string) error {
|
||||
// Sanitize file path to prevent zip slip attacks
|
||||
filePath := filepath.Join(destDir, file.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean(destDir)+string(os.PathSeparator)) {
|
||||
return apperrors.NewValidation(fmt.Sprintf("invalid file path in zip: %s", file.Name))
|
||||
}
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
// Use 0755 to ensure we have write permission for creating files inside
|
||||
return os.MkdirAll(filePath, 0755)
|
||||
}
|
||||
|
||||
// Ensure parent directory exists with write permission
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||||
}
|
||||
|
||||
// Extract file
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Use file mode from zip but ensure at least 0644 for files
|
||||
fileMode := file.Mode()
|
||||
if fileMode&0600 == 0 {
|
||||
fileMode = 0644
|
||||
}
|
||||
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, srcFile); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupTempFile removes a temporary file, ignoring errors.
|
||||
func cleanupTempFile(path string) {
|
||||
if path != "" {
|
||||
os.Remove(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
// 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 (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
func TestResolveSkillTargetPath(t *testing.T) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get home directory: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
wantSuffix string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "qoder target",
|
||||
target: "qoder",
|
||||
wantSuffix: filepath.Join(".qoder", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "claude target",
|
||||
target: "claude",
|
||||
wantSuffix: filepath.Join(".claude", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "cursor target",
|
||||
target: "cursor",
|
||||
wantSuffix: filepath.Join(".cursor", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "codex target",
|
||||
target: "codex",
|
||||
wantSuffix: filepath.Join(".codex", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "opencode target",
|
||||
target: "opencode",
|
||||
wantSuffix: filepath.Join(".config", "opencode", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive - QODER",
|
||||
target: "QODER",
|
||||
wantSuffix: filepath.Join(".qoder", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive - Claude",
|
||||
target: "Claude",
|
||||
wantSuffix: filepath.Join(".claude", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid target",
|
||||
target: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty target",
|
||||
target: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
target: " ",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := resolveSkillTargetPath(tt.target)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("resolveSkillTargetPath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
expected := filepath.Join(homeDir, tt.wantSuffix)
|
||||
if got != expected {
|
||||
t.Errorf("resolveSkillTargetPath() = %v, want %v", got, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
|
||||
// Test "." target returns current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveSkillTargetPath(".")
|
||||
if err != nil {
|
||||
t.Errorf("resolveSkillTargetPath(\".\") error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if got != cwd {
|
||||
t.Errorf("resolveSkillTargetPath(\".\") = %v, want %v", got, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDownloadSkillResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonInput string
|
||||
wantSuccess bool
|
||||
wantURL string
|
||||
wantFile string
|
||||
wantErrCode string
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "successful response",
|
||||
jsonInput: `{
|
||||
"success": true,
|
||||
"result": {
|
||||
"downloadUrl": "https://example.com/skill.zip",
|
||||
"fileName": "my-skill.zip"
|
||||
}
|
||||
}`,
|
||||
wantSuccess: true,
|
||||
wantURL: "https://example.com/skill.zip",
|
||||
wantFile: "my-skill.zip",
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
jsonInput: `{
|
||||
"success": false,
|
||||
"errorCode": "SKILL_NOT_FOUND",
|
||||
"errorMsg": "The skill does not exist"
|
||||
}`,
|
||||
wantSuccess: false,
|
||||
wantErrCode: "SKILL_NOT_FOUND",
|
||||
wantErrMsg: "The skill does not exist",
|
||||
},
|
||||
{
|
||||
name: "success with empty result",
|
||||
jsonInput: `{
|
||||
"success": true,
|
||||
"result": null
|
||||
}`,
|
||||
wantSuccess: true,
|
||||
wantURL: "",
|
||||
wantFile: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var resp downloadSkillResponse
|
||||
if err := json.Unmarshal([]byte(tt.jsonInput), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal JSON: %v", err)
|
||||
}
|
||||
|
||||
if resp.Success != tt.wantSuccess {
|
||||
t.Errorf("Success = %v, want %v", resp.Success, tt.wantSuccess)
|
||||
}
|
||||
|
||||
if tt.wantSuccess && resp.Result != nil {
|
||||
if resp.Result.DownloadURL != tt.wantURL {
|
||||
t.Errorf("DownloadURL = %v, want %v", resp.Result.DownloadURL, tt.wantURL)
|
||||
}
|
||||
if resp.Result.FileName != tt.wantFile {
|
||||
t.Errorf("FileName = %v, want %v", resp.Result.FileName, tt.wantFile)
|
||||
}
|
||||
}
|
||||
|
||||
if !tt.wantSuccess {
|
||||
if resp.ErrorCode != tt.wantErrCode {
|
||||
t.Errorf("ErrorCode = %v, want %v", resp.ErrorCode, tt.wantErrCode)
|
||||
}
|
||||
if resp.ErrorMsg != tt.wantErrMsg {
|
||||
t.Errorf("ErrorMsg = %v, want %v", resp.ErrorMsg, tt.wantErrMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSkillZip(t *testing.T) {
|
||||
// Create a temporary zip file with test content
|
||||
tempDir := t.TempDir()
|
||||
zipPath := filepath.Join(tempDir, "test.zip")
|
||||
destDir := filepath.Join(tempDir, "extracted")
|
||||
|
||||
// Create zip file with test content
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create zip file: %v", err)
|
||||
}
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
|
||||
// Add a file to the zip
|
||||
fileContent := []byte("test content")
|
||||
writer, err := zipWriter.Create("test-file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file in zip: %v", err)
|
||||
}
|
||||
if _, err := writer.Write(fileContent); err != nil {
|
||||
t.Fatalf("failed to write file content: %v", err)
|
||||
}
|
||||
|
||||
// Add a subdirectory with a file
|
||||
writer, err = zipWriter.Create("subdir/nested-file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create nested file in zip: %v", err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("nested content")); err != nil {
|
||||
t.Fatalf("failed to write nested file content: %v", err)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip writer: %v", err)
|
||||
}
|
||||
if err := zipFile.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip file: %v", err)
|
||||
}
|
||||
|
||||
// Extract the zip
|
||||
if err := extractSkillZip(zipPath, destDir); err != nil {
|
||||
t.Fatalf("extractSkillZip() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify extracted files
|
||||
extractedFile := filepath.Join(destDir, "test-file.txt")
|
||||
content, err := os.ReadFile(extractedFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read extracted file: %v", err)
|
||||
}
|
||||
if string(content) != "test content" {
|
||||
t.Errorf("extracted content = %v, want %v", string(content), "test content")
|
||||
}
|
||||
|
||||
// Verify nested file
|
||||
nestedFile := filepath.Join(destDir, "subdir", "nested-file.txt")
|
||||
content, err = os.ReadFile(nestedFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read nested file: %v", err)
|
||||
}
|
||||
if string(content) != "nested content" {
|
||||
t.Errorf("nested content = %v, want %v", string(content), "nested content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSkillZipPreventZipSlip(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
zipPath := filepath.Join(tempDir, "malicious.zip")
|
||||
destDir := filepath.Join(tempDir, "extracted")
|
||||
|
||||
// Create a zip file with a path traversal attempt
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create zip file: %v", err)
|
||||
}
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
|
||||
// Try to create a file with path traversal
|
||||
writer, err := zipWriter.Create("../../../etc/passwd")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create malicious file in zip: %v", err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("malicious content")); err != nil {
|
||||
t.Fatalf("failed to write malicious content: %v", err)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip writer: %v", err)
|
||||
}
|
||||
if err := zipFile.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip file: %v", err)
|
||||
}
|
||||
|
||||
// Extract should fail due to zip slip protection
|
||||
err = extractSkillZip(zipPath, destDir)
|
||||
if err == nil {
|
||||
t.Error("extractSkillZip() should have failed for zip slip attack")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid file path") {
|
||||
t.Errorf("error should mention invalid file path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInstallCommandValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing arguments",
|
||||
args: []string{"skill", "install"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"skill", "install", "skill-123"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
args: []string{"skill", "install", "skill-123", "qoder", "extra"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err := cmd.Execute()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("error = %v, should contain %v", err, tt.errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
// Setup: Create config directory with valid token
|
||||
tempDir := t.TempDir()
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
// Save a valid token
|
||||
err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
|
||||
AccessToken: "test-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("SaveTokenData() unavailable in this environment: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "install", "skill-123", "invalid-target"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err = cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Execute() should have failed for invalid target")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid target") {
|
||||
t.Errorf("error should mention invalid target, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInstallRequiresAuth(t *testing.T) {
|
||||
// Setup: Create config directory without token
|
||||
tempDir := t.TempDir()
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
// Ensure the config directory exists but has no token
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "install", "skill-123", "qoder"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("Execute() should have failed without auth")
|
||||
}
|
||||
// Check for authentication-related error (English or Chinese)
|
||||
errStr := err.Error()
|
||||
if !strings.Contains(errStr, "not logged in") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "未登录") && !strings.Contains(errStr, "auth") {
|
||||
t.Errorf("error should mention authentication, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
|
||||
// Create mock server that returns 401
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// We can't easily test the actual fetchSkillDownloadInfo function
|
||||
// because it uses a hardcoded URL. This test verifies HTTP 401 handling pattern.
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedTargets(t *testing.T) {
|
||||
targets := supportedTargets()
|
||||
|
||||
// Should contain all predefined targets
|
||||
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."}
|
||||
for _, expected := range expectedTargets {
|
||||
if !strings.Contains(targets, expected) {
|
||||
t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSkillPathsCrossPlatform(t *testing.T) {
|
||||
// Verify that paths use platform-appropriate separators
|
||||
for target, path := range agentSkillPaths {
|
||||
if runtime.GOOS == "windows" {
|
||||
if strings.Contains(path, "/") && !strings.Contains(path, "\\") {
|
||||
// On Windows, filepath.Join should use backslashes
|
||||
// But raw map values may use forward slashes
|
||||
t.Logf("Note: %s path '%s' uses forward slashes (will be converted by filepath.Join)", target, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that resolveSkillTargetPath produces valid paths
|
||||
resolved, err := resolveSkillTargetPath(target)
|
||||
if err != nil {
|
||||
t.Errorf("resolveSkillTargetPath(%s) failed: %v", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Path should be absolute
|
||||
if !filepath.IsAbs(resolved) {
|
||||
t.Errorf("resolveSkillTargetPath(%s) returned non-absolute path: %s", target, resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupTempFile(t *testing.T) {
|
||||
// Create a temporary file
|
||||
tempFile, err := os.CreateTemp("", "test-cleanup-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close()
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(tempPath); os.IsNotExist(err) {
|
||||
t.Fatalf("temp file should exist before cleanup")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
cleanupTempFile(tempPath)
|
||||
|
||||
// Verify file is deleted
|
||||
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
|
||||
t.Errorf("temp file should be deleted after cleanup")
|
||||
}
|
||||
|
||||
// Cleanup should not panic on empty path
|
||||
cleanupTempFile("")
|
||||
|
||||
// Cleanup should not panic on non-existent file
|
||||
cleanupTempFile("/nonexistent/path/file.txt")
|
||||
}
|
||||
|
||||
func TestDownloadSkillResponseJSON(t *testing.T) {
|
||||
// Test JSON marshaling/unmarshaling round-trip
|
||||
original := downloadSkillResponse{
|
||||
Success: true,
|
||||
Result: &downloadSkillResult{
|
||||
DownloadURL: "https://example.com/skill.zip",
|
||||
FileName: "skill.zip",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var parsed downloadSkillResponse
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Success != original.Success {
|
||||
t.Errorf("Success mismatch: got %v, want %v", parsed.Success, original.Success)
|
||||
}
|
||||
if parsed.Result.DownloadURL != original.Result.DownloadURL {
|
||||
t.Errorf("DownloadURL mismatch: got %v, want %v", parsed.Result.DownloadURL, original.Result.DownloadURL)
|
||||
}
|
||||
if parsed.Result.FileName != original.Result.FileName {
|
||||
t.Errorf("FileName mismatch: got %v, want %v", parsed.Result.FileName, original.Result.FileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCommandHelp(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "--help"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
output := out.String()
|
||||
// Check for the Long description which is shown in help
|
||||
if !strings.Contains(output, "技能") {
|
||||
t.Errorf("help should mention '技能', got: %s", output)
|
||||
}
|
||||
for _, subcmd := range []string{"install", "search", "get"} {
|
||||
if !strings.Contains(output, subcmd) {
|
||||
t.Errorf("help should mention %q subcommand, got: %s", subcmd, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInstallCommandHelp(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "install", "--help"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
output := out.String()
|
||||
// Should mention supported targets
|
||||
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode"}
|
||||
for _, target := range expectedTargets {
|
||||
if !strings.Contains(output, target) {
|
||||
t.Errorf("help should mention target '%s', got: %s", target, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillGetCommandValidation(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "get"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want missing required flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required flag") {
|
||||
t.Fatalf("error = %v, want required flag message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSearchCommandValidation(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "search"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want missing required flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required flag") {
|
||||
t.Fatalf("error = %v, want required flag message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillFindHintCommand(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "find"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "dws skill search --query") {
|
||||
t.Fatalf("output = %q, want legacy hint", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSkillFileSuccess(t *testing.T) {
|
||||
// Create a mock server that returns a zip file
|
||||
expectedContent := []byte("fake zip content")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(expectedContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Download the file
|
||||
ctx := context.Background()
|
||||
tempPath, err := downloadSkillFile(ctx, server.URL, "test.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadSkillFile() error = %v", err)
|
||||
}
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
// Verify the downloaded content
|
||||
content, err := os.ReadFile(tempPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read downloaded file: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(content, expectedContent) {
|
||||
t.Errorf("downloaded content mismatch: got %v, want %v", content, expectedContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSkillFileServerError(t *testing.T) {
|
||||
// Create a mock server that returns 500
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := downloadSkillFile(ctx, server.URL, "test.zip")
|
||||
if err == nil {
|
||||
t.Error("downloadSkillFile() should fail on server error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSkillZipEmptyZip(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
zipPath := filepath.Join(tempDir, "empty.zip")
|
||||
destDir := filepath.Join(tempDir, "extracted")
|
||||
|
||||
// Create an empty zip file
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create zip file: %v", err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip writer: %v", err)
|
||||
}
|
||||
if err := zipFile.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip file: %v", err)
|
||||
}
|
||||
|
||||
// Extract should succeed even for empty zip
|
||||
if err := extractSkillZip(zipPath, destDir); err != nil {
|
||||
t.Errorf("extractSkillZip() should not fail for empty zip: %v", err)
|
||||
}
|
||||
|
||||
// Destination directory should be created
|
||||
if _, err := os.Stat(destDir); os.IsNotExist(err) {
|
||||
t.Errorf("destination directory should be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSkillZipWithDirectories(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
zipPath := filepath.Join(tempDir, "test.zip")
|
||||
destDir := filepath.Join(tempDir, "extracted")
|
||||
|
||||
// Create zip with directory entries
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create zip file: %v", err)
|
||||
}
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
|
||||
// Add a directory entry with proper permissions
|
||||
header := &zip.FileHeader{
|
||||
Name: "mydir/",
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
header.SetMode(0755 | os.ModeDir)
|
||||
_, err = zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create directory in zip: %v", err)
|
||||
}
|
||||
|
||||
// Add a file in the directory
|
||||
fileHeader := &zip.FileHeader{
|
||||
Name: "mydir/file.txt",
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
fileHeader.SetMode(0644)
|
||||
writer, err := zipWriter.CreateHeader(fileHeader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create file in zip: %v", err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("content")); err != nil {
|
||||
t.Fatalf("failed to write content: %v", err)
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip writer: %v", err)
|
||||
}
|
||||
if err := zipFile.Close(); err != nil {
|
||||
t.Fatalf("failed to close zip file: %v", err)
|
||||
}
|
||||
|
||||
// Extract
|
||||
if err := extractSkillZip(zipPath, destDir); err != nil {
|
||||
t.Fatalf("extractSkillZip() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify directory was created
|
||||
dirPath := filepath.Join(destDir, "mydir")
|
||||
info, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
t.Errorf("directory should exist: %v", err)
|
||||
} else if !info.IsDir() {
|
||||
t.Errorf("mydir should be a directory")
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
filePath := filepath.Join(destDir, "mydir", "file.txt")
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Errorf("file should exist: %v", err)
|
||||
} else if string(content) != "content" {
|
||||
t.Errorf("file content mismatch: got %s, want 'content'", string(content))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
const stdioEndpointScheme = "stdio://"
|
||||
|
||||
var (
|
||||
stdioMu sync.RWMutex
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
)
|
||||
|
||||
// RegisterStdioClient stores a StdioClient keyed by its canonical product ID
|
||||
// (the CLI.ID used in the server descriptor). The runner looks up this client
|
||||
// when a stdio:// endpoint is resolved at execution time.
|
||||
func RegisterStdioClient(productID string, client *transport.StdioClient) {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
stdioClients[productID] = client
|
||||
}
|
||||
|
||||
// LookupStdioClient returns the StdioClient registered for the given product ID.
|
||||
// The productID can be either the full key (pluginName/serverKey) or just the serverKey.
|
||||
// This supports backward compatibility with existing CanonicalProduct values.
|
||||
func LookupStdioClient(productID string) (*transport.StdioClient, bool) {
|
||||
stdioMu.RLock()
|
||||
defer stdioMu.RUnlock()
|
||||
// Try exact match first
|
||||
if c, ok := stdioClients[productID]; ok {
|
||||
return c, true
|
||||
}
|
||||
// If not found, try matching by serverKey suffix (for backward compatibility)
|
||||
for id, c := range stdioClients {
|
||||
if idx := strings.LastIndex(id, "/"); idx >= 0 {
|
||||
if id[idx+1:] == productID {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// StdioEndpoint returns a virtual endpoint URL for a stdio-based MCP server.
|
||||
// Format: stdio://{pluginName}/{serverKey}
|
||||
func StdioEndpoint(pluginName, serverKey string) string {
|
||||
return stdioEndpointScheme + pluginName + "/" + serverKey
|
||||
}
|
||||
|
||||
// IsStdioEndpoint returns true if the endpoint uses the stdio:// scheme.
|
||||
func IsStdioEndpoint(endpoint string) bool {
|
||||
return strings.HasPrefix(endpoint, stdioEndpointScheme)
|
||||
}
|
||||
|
||||
// StopAllStdioClients stops all registered stdio clients.
|
||||
// This should be called on program exit to terminate child processes.
|
||||
func StopAllStdioClients() {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
for id, client := range stdioClients {
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
}
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
}
|
||||
|
||||
// StopStdioClient stops a specific stdio client by product ID.
|
||||
// Returns true if the client was found and stopped, false otherwise.
|
||||
func StopStdioClient(productID string) bool {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
client, ok := stdioClients[productID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", productID, "error", err)
|
||||
}
|
||||
delete(stdioClients, productID)
|
||||
return true
|
||||
}
|
||||
|
||||
// StopStdioClientsByPlugin stops all stdio clients belonging to a plugin.
|
||||
// The productID format is "pluginName/serverKey". This function stops all
|
||||
// clients whose productID has the given pluginName prefix.
|
||||
func StopStdioClientsByPlugin(pluginName string) int {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
prefix := pluginName + "/"
|
||||
count := 0
|
||||
for id, client := range stdioClients {
|
||||
if len(id) > len(prefix) && id[:len(prefix)] == prefix {
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
delete(stdioClients, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestStdioEndpoint(t *testing.T) {
|
||||
endpoint := StdioEndpoint("hello-plugin", "hello")
|
||||
want := "stdio://hello-plugin/hello"
|
||||
if endpoint != want {
|
||||
t.Errorf("StdioEndpoint() = %q, want %q", endpoint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStdioEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
endpoint string
|
||||
want bool
|
||||
}{
|
||||
{"stdio://hello-plugin/hello", true},
|
||||
{"stdio://conference/local", true},
|
||||
{"https://mcp.dingtalk.com", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsStdioEndpoint(tt.endpoint); got != tt.want {
|
||||
t.Errorf("IsStdioEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioClientRegistry(t *testing.T) {
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
stdioMu.Lock()
|
||||
delete(stdioClients, "test-product")
|
||||
stdioMu.Unlock()
|
||||
}()
|
||||
|
||||
// Initially not found
|
||||
if _, ok := LookupStdioClient("test-product"); ok {
|
||||
t.Error("expected LookupStdioClient to return false for unregistered product")
|
||||
}
|
||||
|
||||
// Register a client
|
||||
client := transport.NewStdioClient("echo", nil, nil)
|
||||
RegisterStdioClient("test-product", client)
|
||||
|
||||
// Now should be found
|
||||
got, ok := LookupStdioClient("test-product")
|
||||
if !ok {
|
||||
t.Fatal("expected LookupStdioClient to return true after registration")
|
||||
}
|
||||
if got != client {
|
||||
t.Error("LookupStdioClient returned different client instance")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// TestMain isolates the on-disk keychain storage to a process-wide
|
||||
// temporary directory for the entire internal/app test binary.
|
||||
//
|
||||
// Background: getCachedRuntimeToken caches the auth token via sync.Once
|
||||
// for the process lifetime. Whichever test triggers it first locks in the
|
||||
// cached value. Several tests in this package (e.g. TestSkillInstallInvalidTarget)
|
||||
// call SaveTokenData and then exec a CLI command that triggers Once.Do; if
|
||||
// keychain storage points at the developer's real ~/Library/Application
|
||||
// Support/dws-cli (or ~/.local/share/dws-cli on Linux), a real token can be
|
||||
// written there and cached process-wide, breaking later tests that assume
|
||||
// "no auth" — most notably TestRuntimeRunnerRejectsUnauthenticatedRequest.
|
||||
//
|
||||
// Setting keychain.StorageDirEnv here forces every keychain read/write in
|
||||
// this binary into a per-process tempdir, eliminating that contamination
|
||||
// without touching production code.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
|
||||
if err != nil {
|
||||
panic("create test keychain tempdir: " + err.Error())
|
||||
}
|
||||
if err := os.Setenv(keychain.StorageDirEnv, tmpDir); err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_PERF_DEBUG",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "启用性能计时输出到 stderr",
|
||||
Example: "1",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_PERF_REPORT",
|
||||
Category: configmeta.CategoryDebug,
|
||||
Description: "JSON 性能报告输出路径 (auto=~/.dws/perf/latest.json)",
|
||||
Example: "auto",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// PerfDebugEnv is the environment variable to enable performance timing output.
|
||||
PerfDebugEnv = "DWS_PERF_DEBUG"
|
||||
|
||||
// PerfReportEnv is the environment variable to enable JSON perf report output.
|
||||
// Set to "auto" to write to ~/.dws/perf/latest.json, or a custom file path.
|
||||
PerfReportEnv = "DWS_PERF_REPORT"
|
||||
|
||||
perfReportDir = "perf"
|
||||
perfReportFile = "latest.json"
|
||||
)
|
||||
|
||||
// timingContextKey is the context key for TimingCollector.
|
||||
type timingContextKey struct{}
|
||||
|
||||
// TimingEntry represents a single timing measurement.
|
||||
type TimingEntry struct {
|
||||
Name string
|
||||
Duration time.Duration
|
||||
Timestamp time.Time
|
||||
Seq int // insertion order
|
||||
}
|
||||
|
||||
// TimingCollector collects timing measurements for a single command execution.
|
||||
// It is safe for concurrent use.
|
||||
type TimingCollector struct {
|
||||
mu sync.Mutex
|
||||
start time.Time
|
||||
entries []TimingEntry
|
||||
seq int
|
||||
}
|
||||
|
||||
// NewTimingCollector creates a new collector with the start time set to now.
|
||||
func NewTimingCollector() *TimingCollector {
|
||||
return &TimingCollector{
|
||||
start: time.Now(),
|
||||
entries: make([]TimingEntry, 0, 16),
|
||||
}
|
||||
}
|
||||
|
||||
// Record adds a timing entry with the given name and duration.
|
||||
func (tc *TimingCollector) Record(name string, d time.Duration) {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
tc.entries = append(tc.entries, TimingEntry{
|
||||
Name: name,
|
||||
Duration: d,
|
||||
Timestamp: time.Now(),
|
||||
Seq: tc.seq,
|
||||
})
|
||||
tc.seq++
|
||||
}
|
||||
|
||||
// StartTimer returns a function that, when called, records the elapsed time
|
||||
// since StartTimer was called. This is convenient for defer usage:
|
||||
//
|
||||
// defer tc.StartTimer("operation")()
|
||||
func (tc *TimingCollector) StartTimer(name string) func() {
|
||||
if tc == nil {
|
||||
return func() {}
|
||||
}
|
||||
start := time.Now()
|
||||
return func() {
|
||||
tc.Record(name, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
// Total returns the total elapsed time since the collector was created.
|
||||
func (tc *TimingCollector) Total() time.Duration {
|
||||
if tc == nil {
|
||||
return 0
|
||||
}
|
||||
return time.Since(tc.start)
|
||||
}
|
||||
|
||||
// Entries returns a copy of all recorded entries in insertion order.
|
||||
func (tc *TimingCollector) Entries() []TimingEntry {
|
||||
if tc == nil {
|
||||
return nil
|
||||
}
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
result := make([]TimingEntry, len(tc.entries))
|
||||
copy(result, tc.entries)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Seq < result[j].Seq
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// formatDuration returns a human-friendly duration string.
|
||||
// Sub-µs → "0µs", sub-ms → microsecond precision (e.g. "142µs"), else → ms.
|
||||
func formatDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Microsecond:
|
||||
return "0µs"
|
||||
case d < time.Millisecond:
|
||||
return d.Truncate(time.Microsecond).String()
|
||||
default:
|
||||
return d.Truncate(time.Millisecond).String()
|
||||
}
|
||||
}
|
||||
|
||||
// Print writes a summary of all timing entries to the given writer.
|
||||
func (tc *TimingCollector) Print(w io.Writer) {
|
||||
if tc == nil || w == nil {
|
||||
return
|
||||
}
|
||||
entries := tc.Entries()
|
||||
total := tc.Total()
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintf(w, "\n[Perf] Total: %v (no detailed entries)\n", formatDuration(total))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "[Perf] Execution breakdown:")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, " %-30s %v\n", e.Name, formatDuration(e.Duration))
|
||||
}
|
||||
fmt.Fprintf(w, " %-30s %v\n", "──────────────────────────────", "──────────")
|
||||
fmt.Fprintf(w, " %-30s %v\n", "Total", formatDuration(total))
|
||||
}
|
||||
|
||||
// PrintIfEnabled prints timing info to stderr if DWS_PERF_DEBUG is set.
|
||||
func (tc *TimingCollector) PrintIfEnabled() {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
if os.Getenv(PerfDebugEnv) == "" {
|
||||
return
|
||||
}
|
||||
tc.Print(os.Stderr)
|
||||
}
|
||||
|
||||
// WithTimingCollector returns a new context with the TimingCollector attached.
|
||||
func WithTimingCollector(ctx context.Context, tc *TimingCollector) context.Context {
|
||||
return context.WithValue(ctx, timingContextKey{}, tc)
|
||||
}
|
||||
|
||||
// TimingCollectorFromContext extracts the TimingCollector from context, or nil.
|
||||
func TimingCollectorFromContext(ctx context.Context) *TimingCollector {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
tc, _ := ctx.Value(timingContextKey{}).(*TimingCollector)
|
||||
return tc
|
||||
}
|
||||
|
||||
// RecordTiming is a convenience function to record timing to the collector in context.
|
||||
func RecordTiming(ctx context.Context, name string, d time.Duration) {
|
||||
if tc := TimingCollectorFromContext(ctx); tc != nil {
|
||||
tc.Record(name, d)
|
||||
}
|
||||
}
|
||||
|
||||
// StartTiming is a convenience function that returns a stop function for defer usage.
|
||||
// Example:
|
||||
//
|
||||
// defer StartTiming(ctx, "operation")()
|
||||
func StartTiming(ctx context.Context, name string) func() {
|
||||
tc := TimingCollectorFromContext(ctx)
|
||||
if tc == nil {
|
||||
return func() {}
|
||||
}
|
||||
return tc.StartTimer(name)
|
||||
}
|
||||
|
||||
// IsPerfDebugEnabled returns true if performance debug output is enabled.
|
||||
func IsPerfDebugEnabled() bool {
|
||||
return os.Getenv(PerfDebugEnv) != ""
|
||||
}
|
||||
|
||||
// ── Structured Performance Report ──────────────────────────────────────
|
||||
|
||||
// PerfPhase is a single phase in the performance report.
|
||||
type PerfPhase struct {
|
||||
Name string `json:"name"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Seq int `json:"seq"`
|
||||
}
|
||||
|
||||
// PerfReport is the JSON-serialisable performance report.
|
||||
type PerfReport struct {
|
||||
Kind string `json:"kind"`
|
||||
Version string `json:"version"`
|
||||
CLIVersion string `json:"cli_version"`
|
||||
Command string `json:"command"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TotalMs int64 `json:"total_ms"`
|
||||
Phases []PerfPhase `json:"phases"`
|
||||
Slowest string `json:"slowest"`
|
||||
OverheadMs int64 `json:"overhead_ms"`
|
||||
}
|
||||
|
||||
// BuildReport constructs a PerfReport from the collected timing entries.
|
||||
func (tc *TimingCollector) BuildReport(cliVersion, command string) PerfReport {
|
||||
entries := tc.Entries()
|
||||
total := tc.Total()
|
||||
totalMs := total.Milliseconds()
|
||||
|
||||
phases := make([]PerfPhase, len(entries))
|
||||
var sumMs int64
|
||||
var slowestName string
|
||||
var slowestMs int64
|
||||
|
||||
for i, e := range entries {
|
||||
ms := e.Duration.Milliseconds()
|
||||
phases[i] = PerfPhase{
|
||||
Name: e.Name,
|
||||
DurationMs: ms,
|
||||
Seq: e.Seq,
|
||||
}
|
||||
sumMs += ms
|
||||
if ms > slowestMs {
|
||||
slowestMs = ms
|
||||
slowestName = e.Name
|
||||
}
|
||||
}
|
||||
|
||||
overhead := totalMs - sumMs
|
||||
if overhead < 0 {
|
||||
overhead = 0
|
||||
}
|
||||
|
||||
return PerfReport{
|
||||
Kind: "perf_report",
|
||||
Version: "1",
|
||||
CLIVersion: cliVersion,
|
||||
Command: command,
|
||||
Timestamp: time.Now(),
|
||||
TotalMs: totalMs,
|
||||
Phases: phases,
|
||||
Slowest: slowestName,
|
||||
OverheadMs: overhead,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteReportIfEnabled checks DWS_PERF_REPORT and writes a JSON report if set.
|
||||
func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
dest := os.Getenv(PerfReportEnv)
|
||||
if dest == "" {
|
||||
return
|
||||
}
|
||||
|
||||
report := tc.BuildReport(cliVersion, command)
|
||||
data, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := resolvePerfReportPath(dest)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json).
|
||||
func LoadLatestReport() (*PerfReport, error) {
|
||||
path := defaultPerfReportPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var report PerfReport
|
||||
if err := json.Unmarshal(data, &report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &report, nil
|
||||
}
|
||||
|
||||
// resolvePerfReportPath resolves the DWS_PERF_REPORT value to an absolute path.
|
||||
func resolvePerfReportPath(dest string) string {
|
||||
if dest == "auto" {
|
||||
return defaultPerfReportPath()
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
func defaultPerfReportPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".dws", perfReportDir, perfReportFile)
|
||||
}
|
||||
|
||||
// sensitiveFlags are flag names whose values should be masked in commands.
|
||||
var sensitiveFlags = map[string]bool{
|
||||
"--token": true,
|
||||
"--client-secret": true,
|
||||
"--client-id": true,
|
||||
}
|
||||
|
||||
// SanitizeCommand redacts sensitive flag values from a command arg slice.
|
||||
func SanitizeCommand(args []string) string {
|
||||
sanitized := make([]string, 0, len(args))
|
||||
skipNext := false
|
||||
for _, arg := range args {
|
||||
if skipNext {
|
||||
sanitized = append(sanitized, "***")
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(arg, '='); idx > 0 {
|
||||
key := arg[:idx]
|
||||
if sensitiveFlags[key] {
|
||||
sanitized = append(sanitized, key+"=***")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if sensitiveFlags[arg] {
|
||||
skipNext = true
|
||||
}
|
||||
sanitized = append(sanitized, arg)
|
||||
}
|
||||
return strings.Join(sanitized, " ")
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimingCollector_Basic(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
if tc == nil {
|
||||
t.Fatal("NewTimingCollector returned nil")
|
||||
}
|
||||
|
||||
// Record some timings
|
||||
tc.Record("op1", 10*time.Millisecond)
|
||||
tc.Record("op2", 20*time.Millisecond)
|
||||
|
||||
entries := tc.Entries()
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Check ordering
|
||||
if entries[0].Name != "op1" {
|
||||
t.Errorf("expected first entry to be 'op1', got %q", entries[0].Name)
|
||||
}
|
||||
if entries[1].Name != "op2" {
|
||||
t.Errorf("expected second entry to be 'op2', got %q", entries[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollector_StartTimer(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
|
||||
stop := tc.StartTimer("timed_op")
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
stop()
|
||||
|
||||
entries := tc.Entries()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Name != "timed_op" {
|
||||
t.Errorf("expected entry name 'timed_op', got %q", entries[0].Name)
|
||||
}
|
||||
if entries[0].Duration < 5*time.Millisecond {
|
||||
t.Errorf("expected duration >= 5ms, got %v", entries[0].Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollector_NilSafe(t *testing.T) {
|
||||
var tc *TimingCollector
|
||||
|
||||
// Should not panic on nil collector
|
||||
tc.Record("op", 10*time.Millisecond)
|
||||
stop := tc.StartTimer("op")
|
||||
stop()
|
||||
_ = tc.Total()
|
||||
_ = tc.Entries()
|
||||
tc.Print(nil)
|
||||
tc.PrintIfEnabled()
|
||||
}
|
||||
|
||||
func TestTimingCollector_Print(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("auth_token", 44*time.Millisecond)
|
||||
tc.Record("mcp_call", 150*time.Millisecond)
|
||||
|
||||
var buf bytes.Buffer
|
||||
tc.Print(&buf)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "[Perf]") {
|
||||
t.Error("output should contain [Perf] header")
|
||||
}
|
||||
if !strings.Contains(output, "auth_token") {
|
||||
t.Error("output should contain 'auth_token'")
|
||||
}
|
||||
if !strings.Contains(output, "mcp_call") {
|
||||
t.Error("output should contain 'mcp_call'")
|
||||
}
|
||||
if !strings.Contains(output, "Total") {
|
||||
t.Error("output should contain 'Total'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollector_PrintIfEnabled(t *testing.T) {
|
||||
// Set environment variable
|
||||
os.Setenv(PerfDebugEnv, "1")
|
||||
defer os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("test_op", 10*time.Millisecond)
|
||||
|
||||
// This should not panic and should print to stderr
|
||||
tc.PrintIfEnabled()
|
||||
}
|
||||
|
||||
func TestTimingCollector_ContextIntegration(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
ctx := WithTimingCollector(context.Background(), tc)
|
||||
|
||||
// Retrieve from context
|
||||
retrieved := TimingCollectorFromContext(ctx)
|
||||
if retrieved != tc {
|
||||
t.Error("TimingCollectorFromContext should return the same collector")
|
||||
}
|
||||
|
||||
// Use convenience functions
|
||||
RecordTiming(ctx, "ctx_op", 30*time.Millisecond)
|
||||
stop := StartTiming(ctx, "ctx_timed")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
stop()
|
||||
|
||||
entries := tc.Entries()
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollectorFromContext_NilContext(t *testing.T) {
|
||||
//lint:ignore SA1012 Testing explicit nil-context guard in TimingCollectorFromContext.
|
||||
tc := TimingCollectorFromContext(nil)
|
||||
if tc != nil {
|
||||
t.Error("TimingCollectorFromContext(nil) should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingCollectorFromContext_NoCollector(t *testing.T) {
|
||||
tc := TimingCollectorFromContext(context.Background())
|
||||
if tc != nil {
|
||||
t.Error("TimingCollectorFromContext with no collector should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTiming_NoCollector(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
stop := StartTiming(ctx, "no_collector")
|
||||
// Should not panic
|
||||
stop()
|
||||
}
|
||||
|
||||
func TestIsPerfDebugEnabled(t *testing.T) {
|
||||
// Clear the env var first
|
||||
os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
if IsPerfDebugEnabled() {
|
||||
t.Error("IsPerfDebugEnabled should return false when env var is not set")
|
||||
}
|
||||
|
||||
os.Setenv(PerfDebugEnv, "1")
|
||||
defer os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
if !IsPerfDebugEnabled() {
|
||||
t.Error("IsPerfDebugEnabled should return true when env var is set")
|
||||
}
|
||||
}
|
||||
|
||||
// ── PerfReport tests ────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildReport(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 45*time.Millisecond)
|
||||
tc.Record("auth_keychain", 72*time.Millisecond)
|
||||
tc.Record("mcp_call", 620*time.Millisecond)
|
||||
|
||||
report := tc.BuildReport("v1.0.8", "dws aitable list-records")
|
||||
|
||||
if report.Kind != "perf_report" {
|
||||
t.Errorf("expected kind 'perf_report', got %q", report.Kind)
|
||||
}
|
||||
if report.Version != "1" {
|
||||
t.Errorf("expected version '1', got %q", report.Version)
|
||||
}
|
||||
if report.CLIVersion != "v1.0.8" {
|
||||
t.Errorf("expected cli_version 'v1.0.8', got %q", report.CLIVersion)
|
||||
}
|
||||
if report.Command != "dws aitable list-records" {
|
||||
t.Errorf("expected command 'dws aitable list-records', got %q", report.Command)
|
||||
}
|
||||
if len(report.Phases) != 3 {
|
||||
t.Fatalf("expected 3 phases, got %d", len(report.Phases))
|
||||
}
|
||||
if report.Phases[0].Name != "cmd_init" || report.Phases[0].DurationMs != 45 {
|
||||
t.Errorf("unexpected first phase: %+v", report.Phases[0])
|
||||
}
|
||||
if report.Slowest != "mcp_call" {
|
||||
t.Errorf("expected slowest 'mcp_call', got %q", report.Slowest)
|
||||
}
|
||||
if report.TotalMs < 0 {
|
||||
t.Errorf("total_ms should be >= 0, got %d", report.TotalMs)
|
||||
}
|
||||
if report.OverheadMs < 0 {
|
||||
t.Errorf("overhead_ms should be >= 0, got %d", report.OverheadMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportEmpty(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
report := tc.BuildReport("dev", "dws version")
|
||||
|
||||
if len(report.Phases) != 0 {
|
||||
t.Errorf("expected 0 phases, got %d", len(report.Phases))
|
||||
}
|
||||
if report.Slowest != "" {
|
||||
t.Errorf("expected empty slowest, got %q", report.Slowest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportJSON(t *testing.T) {
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 10*time.Millisecond)
|
||||
|
||||
report := tc.BuildReport("v1.0.0", "dws version")
|
||||
data, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
requiredKeys := []string{"kind", "version", "cli_version", "command", "timestamp", "total_ms", "phases", "slowest", "overhead_ms"}
|
||||
for _, key := range requiredKeys {
|
||||
if _, ok := parsed[key]; !ok {
|
||||
t.Errorf("missing key %q in JSON output", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reportPath := filepath.Join(dir, "report.json")
|
||||
|
||||
t.Setenv(PerfReportEnv, reportPath)
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 50*time.Millisecond)
|
||||
tc.Record("mcp_call", 200*time.Millisecond)
|
||||
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
|
||||
data, err := os.ReadFile(reportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("report file not written: %v", err)
|
||||
}
|
||||
|
||||
var report PerfReport
|
||||
if err := json.Unmarshal(data, &report); err != nil {
|
||||
t.Fatalf("invalid JSON in report: %v", err)
|
||||
}
|
||||
if report.Kind != "perf_report" {
|
||||
t.Errorf("expected kind 'perf_report', got %q", report.Kind)
|
||||
}
|
||||
if len(report.Phases) != 2 {
|
||||
t.Errorf("expected 2 phases, got %d", len(report.Phases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled_Auto(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
expected := filepath.Join(tmpHome, ".dws", "perf", "latest.json")
|
||||
|
||||
// Temporarily override HOME for defaultPerfReportPath
|
||||
t.Setenv("HOME", tmpHome)
|
||||
t.Setenv(PerfReportEnv, "auto")
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("cmd_init", 10*time.Millisecond)
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
|
||||
if _, err := os.Stat(expected); err != nil {
|
||||
t.Fatalf("expected report at %s: %v", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled_Disabled(t *testing.T) {
|
||||
t.Setenv(PerfReportEnv, "")
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("op", 10*time.Millisecond)
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
// No file should be written; no error expected
|
||||
}
|
||||
|
||||
func TestWriteReportIfEnabled_NilCollector(t *testing.T) {
|
||||
t.Setenv(PerfReportEnv, "/tmp/should-not-exist.json")
|
||||
var tc *TimingCollector
|
||||
tc.WriteReportIfEnabled("v1.0.0", "dws version")
|
||||
}
|
||||
|
||||
func TestLoadLatestReport(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
|
||||
perfDir := filepath.Join(tmpHome, ".dws", "perf")
|
||||
if err := os.MkdirAll(perfDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report := PerfReport{
|
||||
Kind: "perf_report",
|
||||
Version: "1",
|
||||
CLIVersion: "v1.0.0",
|
||||
Command: "dws version",
|
||||
TotalMs: 100,
|
||||
Phases: []PerfPhase{{Name: "cmd_init", DurationMs: 50, Seq: 0}},
|
||||
Slowest: "cmd_init",
|
||||
OverheadMs: 50,
|
||||
}
|
||||
data, _ := json.MarshalIndent(report, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(perfDir, "latest.json"), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := LoadLatestReport()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLatestReport failed: %v", err)
|
||||
}
|
||||
if loaded.CLIVersion != "v1.0.0" {
|
||||
t.Errorf("expected cli_version 'v1.0.0', got %q", loaded.CLIVersion)
|
||||
}
|
||||
if len(loaded.Phases) != 1 {
|
||||
t.Errorf("expected 1 phase, got %d", len(loaded.Phases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLatestReport_NotFound(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
t.Setenv("HOME", tmpHome)
|
||||
|
||||
_, err := LoadLatestReport()
|
||||
if err == nil {
|
||||
t.Error("expected error when report file does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no sensitive flags",
|
||||
args: []string{"dws", "aitable", "list-records"},
|
||||
want: "dws aitable list-records",
|
||||
},
|
||||
{
|
||||
name: "token with space-separated value",
|
||||
args: []string{"dws", "--token", "secret123", "version"},
|
||||
want: "dws --token *** version",
|
||||
},
|
||||
{
|
||||
name: "token with equals sign",
|
||||
args: []string{"dws", "--token=secret123", "version"},
|
||||
want: "dws --token=*** version",
|
||||
},
|
||||
{
|
||||
name: "client-secret space-separated",
|
||||
args: []string{"dws", "--client-secret", "mysecret", "--client-id", "myid", "auth"},
|
||||
want: "dws --client-secret *** --client-id *** auth",
|
||||
},
|
||||
{
|
||||
name: "client-id with equals",
|
||||
args: []string{"dws", "--client-id=abc123"},
|
||||
want: "dws --client-id=***",
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SanitizeCommand(tt.args)
|
||||
if got != tt.want {
|
||||
t.Errorf("SanitizeCommand(%v) = %q, want %q", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePerfReportPath_Auto(t *testing.T) {
|
||||
p := resolvePerfReportPath("auto")
|
||||
if p == "" {
|
||||
t.Skip("HOME not available")
|
||||
}
|
||||
if !strings.HasSuffix(p, filepath.Join("perf", "latest.json")) {
|
||||
t.Errorf("expected path ending in perf/latest.json, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePerfReportPath_Custom(t *testing.T) {
|
||||
p := resolvePerfReportPath("/tmp/my-report.json")
|
||||
if p != "/tmp/my-report.json" {
|
||||
t.Errorf("expected '/tmp/my-report.json', got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPerfReportSummary(t *testing.T) {
|
||||
report := &PerfReport{
|
||||
Command: "dws version",
|
||||
Timestamp: time.Now(),
|
||||
TotalMs: 300,
|
||||
Phases: []PerfPhase{{Name: "cmd_init", DurationMs: 50, Seq: 0}, {Name: "mcp_call", DurationMs: 200, Seq: 1}},
|
||||
Slowest: "mcp_call",
|
||||
OverheadMs: 50,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printPerfReportSummary(&buf, report)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "cmd_init") {
|
||||
t.Error("output should contain 'cmd_init'")
|
||||
}
|
||||
if !strings.Contains(out, "mcp_call") {
|
||||
t.Error("output should contain 'mcp_call'")
|
||||
}
|
||||
if !strings.Contains(out, "← 最慢") {
|
||||
t.Error("output should contain '← 最慢' marker")
|
||||
}
|
||||
if !strings.Contains(out, "总耗时") {
|
||||
t.Error("output should contain '总耗时'")
|
||||
}
|
||||
if !strings.Contains(out, "框架开销") {
|
||||
t.Error("output should contain '框架开销'")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// toolCallerAdapter bridges executor.Runner to the public edition.ToolCaller
|
||||
// interface so that private overlays can invoke MCP tools without importing
|
||||
// internal packages.
|
||||
type toolCallerAdapter struct {
|
||||
runner executor.Runner
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller {
|
||||
return &toolCallerAdapter{runner: runner, flags: flags}
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
|
||||
result, err := a.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertResult(result), nil
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Format() string {
|
||||
if a.flags != nil {
|
||||
return a.flags.Format
|
||||
}
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) DryRun() bool {
|
||||
return a.flags != nil && a.flags.DryRun
|
||||
}
|
||||
|
||||
func convertResult(r executor.Result) *edition.ToolResult {
|
||||
resp := r.Response
|
||||
if resp == nil {
|
||||
// After the fix-wukong-discovery-missing-servers Phase 3 change,
|
||||
// runtimeRunner.Run returns an explicit error for catalog misses
|
||||
// instead of an empty Response, so this branch should only be
|
||||
// reachable for unit tests / unexpected runners. Log a warning so
|
||||
// any future regression (silent `{"Content": null}` on the CLI)
|
||||
// leaves a trace in the file logger / stderr.
|
||||
slog.Warn(
|
||||
"tool_caller_adapter: empty runner response — upstream should surface an error instead",
|
||||
"product", r.Invocation.CanonicalProduct,
|
||||
"tool", r.Invocation.Tool,
|
||||
"kind", r.Invocation.Kind,
|
||||
"dry_run", r.Invocation.DryRun,
|
||||
)
|
||||
return &edition.ToolResult{}
|
||||
}
|
||||
|
||||
// The runtime runner stores MCP response content under "content".
|
||||
contentRaw, ok := resp["content"]
|
||||
if !ok {
|
||||
// Dry-run or echo mode: serialize the whole response as text.
|
||||
data, _ := json.Marshal(resp)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
}
|
||||
|
||||
// Content may be a []any of {type, text} blocks from the MCP response,
|
||||
// or a single map for mock mode.
|
||||
switch v := contentRaw.(type) {
|
||||
case []any:
|
||||
blocks := make([]edition.ContentBlock, 0, len(v))
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
blocks = append(blocks, edition.ContentBlock{
|
||||
Type: strVal(m, "type"),
|
||||
Text: strVal(m, "text"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return &edition.ToolResult{Content: blocks}
|
||||
case map[string]any:
|
||||
data, _ := json.Marshal(v)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
default:
|
||||
data, _ := json.Marshal(contentRaw)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strVal(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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()
|
||||
)
|
||||
|
||||
const defaultListLimit = 10
|
||||
|
||||
func newUpgradeCommand() *cobra.Command {
|
||||
var (
|
||||
flagCheck bool
|
||||
flagList bool
|
||||
flagVersion string
|
||||
flagRollback bool
|
||||
flagForce bool
|
||||
flagSkipSkills bool
|
||||
flagAll bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "升级 DWS CLI 到最新版本",
|
||||
Long: `检查并升级 DWS CLI 到最新版本。
|
||||
|
||||
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
|
||||
Example: ` dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出最近版本
|
||||
dws upgrade --list --all # 列出所有版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if h := edition.Get(); h != nil && h.IsEmbedded {
|
||||
name := h.Name
|
||||
if name == "" {
|
||||
name = "embedded"
|
||||
}
|
||||
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
limit := defaultListLimit
|
||||
if flagAll {
|
||||
limit = 0
|
||||
}
|
||||
return runUpgradeList(cmd, format, limit)
|
||||
}
|
||||
if flagRollback {
|
||||
return runUpgradeRollback(yes)
|
||||
}
|
||||
if flagCheck {
|
||||
return runUpgradeCheck(cmd, format)
|
||||
}
|
||||
return runUpgrade(cmd.Context(), upgradeOptions{
|
||||
targetVersion: flagVersion,
|
||||
force: flagForce,
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出可用版本")
|
||||
cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所有版本")
|
||||
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
|
||||
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
|
||||
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type upgradeOptions struct {
|
||||
targetVersion string
|
||||
force bool
|
||||
skipSkills bool
|
||||
yes bool
|
||||
}
|
||||
|
||||
// --- dws upgrade --check ---
|
||||
|
||||
func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
}
|
||||
|
||||
latest, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查更新失败: %w", err)
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version)
|
||||
|
||||
if format == "json" {
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
||||
"current_version": ensureV(currentVer),
|
||||
"latest_version": "v" + latest.Version,
|
||||
"needs_upgrade": needsUpgrade,
|
||||
"release_date": latest.Date,
|
||||
"prerelease": latest.Prerelease,
|
||||
"changelog": parseChangelogEntries(latest.Changelog, 10),
|
||||
"release_url": latest.HTMLURL,
|
||||
})
|
||||
}
|
||||
|
||||
if !needsUpgrade {
|
||||
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+latest.Version))
|
||||
if latest.Date != "" {
|
||||
fmt.Printf(" %s %s\n", ugBold("发布日期: "), latest.Date)
|
||||
}
|
||||
if latest.Prerelease {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
}
|
||||
if entries := parseChangelogEntries(latest.Changelog, 5); len(entries) > 0 {
|
||||
fmt.Printf(" %s\n", ugBold("更新内容:"))
|
||||
for _, e := range entries {
|
||||
fmt.Printf(" %s %s\n", ugGreen("•"), e)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("运行 dws upgrade 进行升级"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- dws upgrade --list ---
|
||||
|
||||
// runUpgradeList displays available versions. When limit > 0, only the most
|
||||
// recent `limit` versions are shown; pass 0 to show all (--all flag).
|
||||
func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
fmt.Printf(" %s\n", ugDim("获取版本列表..."))
|
||||
}
|
||||
|
||||
versions, err := client.FetchAllReleases()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取版本列表失败: %w", err)
|
||||
}
|
||||
|
||||
totalCount := len(versions)
|
||||
truncated := false
|
||||
if limit > 0 && len(versions) > limit {
|
||||
versions = versions[:limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
currentVer := strings.TrimPrefix(version, "v")
|
||||
|
||||
if format == "json" {
|
||||
var items []map[string]any
|
||||
for _, v := range versions {
|
||||
items = append(items, map[string]any{
|
||||
"version": "v" + v.Version,
|
||||
"date": v.Date,
|
||||
"prerelease": v.Prerelease,
|
||||
"installed": v.Version == currentVer,
|
||||
"changelog": parseChangelogEntries(v.Changelog, 10),
|
||||
})
|
||||
}
|
||||
result := map[string]any{
|
||||
"current_version": ensureV(version),
|
||||
"versions": items,
|
||||
"total": totalCount,
|
||||
}
|
||||
if truncated {
|
||||
result["truncated"] = true
|
||||
result["shown"] = limit
|
||||
}
|
||||
return writeJSON(cmd.OutOrStdout(), result)
|
||||
}
|
||||
|
||||
if totalCount == 0 {
|
||||
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugBold(fmt.Sprintf("%-12s %-12s %-12s %s", "VERSION", "DATE", "TYPE", "CHANGELOG")))
|
||||
fmt.Printf(" %s\n", ugDim(strings.Repeat("─", 70)))
|
||||
|
||||
for _, v := range versions {
|
||||
releaseType := ugGreen("stable")
|
||||
if v.Prerelease {
|
||||
releaseType = ugYellow("pre-release")
|
||||
}
|
||||
versionStr := fmt.Sprintf("v%-11s", v.Version)
|
||||
marker := ""
|
||||
if v.Version == currentVer {
|
||||
versionStr = ugBoldGrn(versionStr)
|
||||
marker = ugCyan(" ← 已安装")
|
||||
}
|
||||
changelog := ugDim(truncateChangelogForList(v.Changelog, 40))
|
||||
fmt.Printf(" %s %-12s %-23s %s%s\n", versionStr, v.Date, releaseType, changelog, marker)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ugBold("当前版本:"), ugBoldGrn(ensureV(version)))
|
||||
if truncated {
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("显示最近 %d 个版本(共 %d 个),使用 --list --all 查看全部", limit, totalCount)))
|
||||
}
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.7 安装指定版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- dws upgrade --rollback ---
|
||||
|
||||
func runUpgradeRollback(yes bool) error {
|
||||
rm := upgrade.NewRollbackManager()
|
||||
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取备份列表失败: %w", err)
|
||||
}
|
||||
if len(backups) == 0 {
|
||||
return fmt.Errorf("没有可用的备份,无法回滚")
|
||||
}
|
||||
|
||||
target := backups[0]
|
||||
targetVer := ensureV(target.Version)
|
||||
currentVer := ensureV(version)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" 当前版本: %s\n", ugBold(currentVer))
|
||||
fmt.Printf(" 回滚目标: %s %s\n", ugCyan(targetVer), ugDim("("+target.CreatedAt.Format("2006-01-02 15:04")+")"))
|
||||
|
||||
if !yes {
|
||||
fmt.Println()
|
||||
fmt.Printf("是否回滚到 %s? [y/N] ", ugBold(targetVer))
|
||||
var answer string
|
||||
fmt.Scanln(&answer)
|
||||
if answer != "y" && answer != "Y" {
|
||||
fmt.Println("已取消")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Print(" 回滚中...")
|
||||
if err := rm.RollbackTo(target); err != nil {
|
||||
return fmt.Errorf("\n回滚失败: %w", err)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Printf(" %s 已回滚 %s %s %s\n", ugBoldGrn("✔"), ugDim(currentVer), ugBold("→"), ugBoldGrn(targetVer))
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- dws upgrade (full) ---
|
||||
//
|
||||
// The upgrade flow is split into two phases for atomicity:
|
||||
// Phase 1 (Prepare): download, verify, extract — all in a temp directory, zero side effects.
|
||||
// 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.
|
||||
|
||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
|
||||
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
|
||||
return fmt.Errorf("初始化目录结构失败: %w", err)
|
||||
}
|
||||
|
||||
upgrade.CleanupStaleFiles()
|
||||
|
||||
client := upgrade.NewClient()
|
||||
var release *upgrade.ReleaseInfo
|
||||
var err error
|
||||
|
||||
if opts.targetVersion != "" {
|
||||
fmt.Printf(" 指定版本: %s\n", ugCyan("v"+opts.targetVersion))
|
||||
release, err = client.FetchReleaseByTag(opts.targetVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取版本 %s 信息失败: %w", opts.targetVersion, err)
|
||||
}
|
||||
} else {
|
||||
release, err = client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查更新失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) {
|
||||
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
|
||||
if release.Date != "" {
|
||||
fmt.Printf(" %s %s\n", ugBold("发布日期: "), release.Date)
|
||||
}
|
||||
if release.Prerelease {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
}
|
||||
|
||||
if !opts.yes {
|
||||
fmt.Println()
|
||||
fmt.Printf("是否升级? [y/N] ")
|
||||
var answer string
|
||||
fmt.Scanln(&answer)
|
||||
if answer != "y" && answer != "Y" {
|
||||
fmt.Println("已取消")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
|
||||
if err != nil {
|
||||
tmpDir, err = os.MkdirTemp("", "dws-upgrade-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
||||
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
|
||||
const totalSteps = 5
|
||||
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
|
||||
|
||||
// ========================================================================
|
||||
// Phase 1: Prepare (download + verify + extract — no side effects)
|
||||
// ========================================================================
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// --- Step 1: Backup ---
|
||||
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
|
||||
rm := upgrade.NewRollbackManager()
|
||||
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
|
||||
if backupErr != nil {
|
||||
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
|
||||
} else {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
// Fetch checksums.txt (needed for strict verification of both binary and skills)
|
||||
var checksumsContent string
|
||||
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
|
||||
if checksumsAsset != nil {
|
||||
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
|
||||
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
|
||||
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
|
||||
checksumsContent = string(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 2: Download (binary + skills together) ---
|
||||
sl := stepFmt(2)
|
||||
progressPrefix := fmt.Sprintf(" %s 下载 %s", sl, ugCyan(binaryAsset.Name))
|
||||
fmt.Print(progressPrefix)
|
||||
start := time.Now()
|
||||
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
|
||||
n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
|
||||
func(percent float64, downloaded, total int64) {
|
||||
bar := progressBar(percent)
|
||||
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("下载二进制失败: %w", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
clearLine := strings.Repeat(" ", 100)
|
||||
|
||||
var skillsZipPath string
|
||||
if hasSkills {
|
||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
||||
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
|
||||
|
||||
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
|
||||
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
|
||||
if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包下载失败: %w", dlErr)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
} else {
|
||||
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
|
||||
}
|
||||
|
||||
// --- Step 3: Verify SHA256 (binary + skills together) ---
|
||||
if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasSkills {
|
||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
||||
if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 4: Extract + validate ---
|
||||
fmt.Printf(" %s 解压并验证...", stepFmt(4))
|
||||
extractDir := filepath.Join(tmpDir, "extracted")
|
||||
if strings.HasSuffix(binaryAsset.Name, ".zip") {
|
||||
if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := extractTarGz(binaryArchivePath, extractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("解压失败: %w", err)
|
||||
}
|
||||
}
|
||||
binaryPath := upgrade.FindBinaryInDir(extractDir)
|
||||
if binaryPath == "" {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
|
||||
}
|
||||
if err := validateNewBinary(binaryPath, release.Version); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("验证失败: %w", err)
|
||||
}
|
||||
|
||||
var skillSrc string
|
||||
if hasSkills {
|
||||
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
|
||||
os.MkdirAll(skillsExtractDir, 0755)
|
||||
if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
|
||||
}
|
||||
skillSrc = upgrade.LocateSkillMD(skillsExtractDir)
|
||||
if skillSrc == "" {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
|
||||
// ========================================================================
|
||||
// Phase 2: Apply (all preparations succeeded — now do the actual changes)
|
||||
// ========================================================================
|
||||
|
||||
// --- Step 5: Replace binary + install skills ---
|
||||
fmt.Printf(" %s 替换并安装...", stepFmt(5))
|
||||
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("替换二进制失败: %w", err)
|
||||
}
|
||||
|
||||
if hasSkills {
|
||||
result, installErr := upgrade.UpgradeSkillLocations(skillSrc)
|
||||
if installErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包安装失败: %w", installErr)
|
||||
}
|
||||
failed := result.Failed()
|
||||
if len(failed) > 0 {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
for _, d := range failed {
|
||||
fmt.Printf(" %s %s %s\n", ugRed("✗"), shortenHome(d.Dir), ugDim(d.Err.Error()))
|
||||
}
|
||||
return fmt.Errorf("技能包安装到 %d 个目录失败,请检查权限后手动重试: dws upgrade --force", len(failed))
|
||||
}
|
||||
succeeded := result.Succeeded()
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("二进制已替换"))
|
||||
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim(fmt.Sprintf("技能包已安装 (%d 个位置)", len(succeeded))))
|
||||
for _, d := range succeeded {
|
||||
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
// Cleanup old backups
|
||||
rm.Cleanup(5)
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Printf(" %s 升级完成 %s %s %s\n", ugBoldGrn("✔"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
|
||||
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
|
||||
fmt.Printf(" %s\n", ugDim("如遇问题,运行 dws upgrade --rollback 回滚"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictVerifyFile performs SHA256 verification with strict semantics:
|
||||
// - If checksum info is available and matches → ✓
|
||||
// - If checksum info is available but MISMATCHES → error (abort upgrade)
|
||||
// - If no checksum info at all → skip (no data to compare against)
|
||||
func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent string) error {
|
||||
fmt.Printf(" %s 校验 %s...", label, fileName)
|
||||
|
||||
// Source 1: checksums.txt
|
||||
if checksumsContent != "" {
|
||||
checksums := upgrade.ParseChecksumFile(checksumsContent)
|
||||
if expectedHash, ok := checksums[fileName]; ok {
|
||||
if err := upgrade.VerifySHA256(filePath, expectedHash); err != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: GitHub asset digest
|
||||
if digest := upgrade.ExtractDigestSHA256(assetDigest); digest != "" {
|
||||
if err := upgrade.VerifySHA256(filePath, digest); err != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
|
||||
}
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// No checksum info available at all
|
||||
fmt.Printf(" %s\n", ugDim("- 跳过 (无可用校验信息)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNewBinary checks the downloaded binary is valid.
|
||||
func validateNewBinary(binaryPath, expectedVersion string) error {
|
||||
info, err := os.Stat(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("文件不存在: %w", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return fmt.Errorf("文件为空")
|
||||
}
|
||||
if err := os.Chmod(binaryPath, 0755); err != nil {
|
||||
return fmt.Errorf("设置执行权限失败: %w", err)
|
||||
}
|
||||
|
||||
out, err := tryExecVersion(binaryPath)
|
||||
if err != nil {
|
||||
// Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid.
|
||||
// Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once.
|
||||
if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) {
|
||||
if repairErr := repairDarwinBinary(binaryPath); repairErr == nil {
|
||||
out, err = tryExecVersion(binaryPath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("二进制无法执行: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(string(out), expectedVersion) {
|
||||
// Not fatal, version format might differ
|
||||
fmt.Printf("\n 注意: 版本输出中未包含 %s", expectedVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryExecVersion(binaryPath string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
|
||||
}
|
||||
|
||||
// isLikelyAMFIKill returns true when err looks like macOS amfid SIGKILL'ing an
|
||||
// unsigned binary. Go reports this as "signal: killed".
|
||||
func isLikelyAMFIKill(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "signal: killed") || strings.Contains(msg, "signal: kill")
|
||||
}
|
||||
|
||||
// repairDarwinBinary applies an ad-hoc codesign and clears the quarantine xattr.
|
||||
// Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon.
|
||||
func repairDarwinBinary(binaryPath string) error {
|
||||
// Best-effort: strip quarantine. Failure is fine (attribute often absent).
|
||||
_ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run()
|
||||
|
||||
if _, err := exec.LookPath("codesign"); err != nil {
|
||||
return fmt.Errorf("codesign 不可用: %w", err)
|
||||
}
|
||||
out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarGz extracts a .tar.gz file using the system tar command.
|
||||
func extractTarGz(archivePath, destDir string) error {
|
||||
os.MkdirAll(destDir, 0755)
|
||||
cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("tar 解压失败: %v: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func progressBar(percent float64) string {
|
||||
width := 20
|
||||
filled := int(percent / 100 * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
return strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
|
||||
}
|
||||
|
||||
// parseChangelogEntries extracts human-readable commit messages from a
|
||||
// GitHub Release body. The body typically looks like:
|
||||
//
|
||||
// ## Changelog
|
||||
// * abcdef1234 - some commit message
|
||||
// * 0123456789 Merge branch 'main' into main
|
||||
//
|
||||
// We strip the hash prefix and skip noisy entries (Merge branch, Merge pull request).
|
||||
func parseChangelogEntries(body string, maxEntries int) []string {
|
||||
var entries []string
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
line = strings.TrimPrefix(line, "- ")
|
||||
|
||||
msg := stripCommitHash(line)
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
if isNoiseCommit(msg) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, msg)
|
||||
if maxEntries > 0 && len(entries) >= maxEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// truncateChangelog returns a short one-line summary for the --check output.
|
||||
func truncateChangelog(body string) string {
|
||||
entries := parseChangelogEntries(body, 3)
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(entries, "; ")
|
||||
}
|
||||
|
||||
// truncateChangelogForList returns a compact summary for the --list table.
|
||||
func truncateChangelogForList(body string, maxLen int) string {
|
||||
entries := parseChangelogEntries(body, 2)
|
||||
if len(entries) == 0 {
|
||||
return "-"
|
||||
}
|
||||
summary := strings.Join(entries, "; ")
|
||||
if len(summary) > maxLen {
|
||||
return summary[:maxLen-3] + "..."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// stripCommitHash removes a leading Git commit hash (7-40 hex chars)
|
||||
// and optional separator (" - ", " ") from a line.
|
||||
func stripCommitHash(line string) string {
|
||||
if len(line) < 8 {
|
||||
return line
|
||||
}
|
||||
// Check if line starts with hex chars (commit hash)
|
||||
hashEnd := 0
|
||||
for hashEnd < len(line) && hashEnd < 40 {
|
||||
c := line[hashEnd]
|
||||
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
|
||||
hashEnd++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if hashEnd < 7 {
|
||||
return line
|
||||
}
|
||||
rest := line[hashEnd:]
|
||||
rest = strings.TrimPrefix(rest, " - ")
|
||||
rest = strings.TrimLeft(rest, " ")
|
||||
return rest
|
||||
}
|
||||
|
||||
func isNoiseCommit(msg string) bool {
|
||||
lower := strings.ToLower(msg)
|
||||
noisePatterns := []string{
|
||||
"merge branch",
|
||||
"merge pull request",
|
||||
"merge remote-tracking",
|
||||
}
|
||||
for _, p := range noisePatterns {
|
||||
if strings.HasPrefix(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureV ensures a version string has a "v" prefix for display consistency.
|
||||
// Non-semver values like "dev" or "unknown" are returned as-is.
|
||||
func ensureV(ver string) string {
|
||||
if ver == "" {
|
||||
return "v0.0.0"
|
||||
}
|
||||
if strings.HasPrefix(ver, "v") {
|
||||
return ver
|
||||
}
|
||||
// Only add "v" prefix for semver-like strings (starts with digit)
|
||||
if len(ver) > 0 && ver[0] >= '0' && ver[0] <= '9' {
|
||||
return "v" + ver
|
||||
}
|
||||
return ver
|
||||
}
|
||||
|
||||
// resolveUpgradeFormat returns "json" only when the user explicitly passes -f json.
|
||||
// Unlike other commands, upgrade defaults to table (human-friendly) output.
|
||||
func resolveUpgradeFormat(cmd *cobra.Command) string {
|
||||
pf := cmd.Root().PersistentFlags()
|
||||
if pf.Changed("format") {
|
||||
if f, err := pf.GetString("format"); err == nil {
|
||||
return strings.ToLower(strings.TrimSpace(f))
|
||||
}
|
||||
}
|
||||
return "table"
|
||||
}
|
||||
|
||||
func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
func shortenHome(path string) string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
if strings.HasPrefix(path, homeDir) {
|
||||
return "~" + path[len(homeDir):]
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"check", []string{"--check"}},
|
||||
{"list", []string{"--list"}},
|
||||
{"rollback", []string{"--rollback"}},
|
||||
{"plain", []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs(tc.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "嵌入模式") {
|
||||
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "embedded") {
|
||||
t.Errorf("error message should include edition name, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "dws upgrade") {
|
||||
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
|
||||
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// --- ensureV ---
|
||||
|
||||
func TestEnsureV(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"1.0.6", "v1.0.6"},
|
||||
{"v1.0.6", "v1.0.6"},
|
||||
{"0.0.1", "v0.0.1"},
|
||||
{"dev", "dev"},
|
||||
{"unknown", "unknown"},
|
||||
{"", "v0.0.0"},
|
||||
{"v", "v"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ensureV(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("ensureV(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseChangelogEntries ---
|
||||
|
||||
func TestParseChangelogEntries(t *testing.T) {
|
||||
body := `## Changelog
|
||||
* abcdef1234567 - fix login bug
|
||||
* 0123456789abc Merge branch 'main' into main
|
||||
* fedcba9876543 - add upgrade command
|
||||
* deadbeef12345 Merge pull request #42
|
||||
* 1234567890abc - improve error handling
|
||||
`
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("len(entries) = %d, want 3 (merge commits should be filtered)", len(entries))
|
||||
}
|
||||
if entries[0] != "fix login bug" {
|
||||
t.Errorf("entries[0] = %q, want %q", entries[0], "fix login bug")
|
||||
}
|
||||
if entries[1] != "add upgrade command" {
|
||||
t.Errorf("entries[1] = %q, want %q", entries[1], "add upgrade command")
|
||||
}
|
||||
if entries[2] != "improve error handling" {
|
||||
t.Errorf("entries[2] = %q, want %q", entries[2], "improve error handling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_MaxLimit(t *testing.T) {
|
||||
body := "* abc1234 - msg1\n* def5678 - msg2\n* ghi9012 - msg3\n"
|
||||
entries := parseChangelogEntries(body, 2)
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("len = %d, want 2 (should respect maxEntries)", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_EmptyBody(t *testing.T) {
|
||||
entries := parseChangelogEntries("", 10)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("len = %d, want 0 for empty body", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_OnlyHeaders(t *testing.T) {
|
||||
body := "## Changelog\n## Another heading\n"
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("len = %d, want 0 for headers-only body", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_OnlyMergeCommits(t *testing.T) {
|
||||
body := "* abc1234 Merge branch 'main'\n* def5678 Merge pull request #10\n"
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("len = %d, want 0 (all merge commits should be filtered)", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangelogEntries_DashPrefixedLines(t *testing.T) {
|
||||
body := "- fix bug\n- add feature\n"
|
||||
entries := parseChangelogEntries(body, 10)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(entries))
|
||||
}
|
||||
if entries[0] != "fix bug" {
|
||||
t.Errorf("entries[0] = %q, want %q", entries[0], "fix bug")
|
||||
}
|
||||
}
|
||||
|
||||
// --- stripCommitHash ---
|
||||
|
||||
func TestStripCommitHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"abcdef1234567 - fix bug", "fix bug"},
|
||||
{"abcdef1234567 fix bug", "fix bug"},
|
||||
{"short", "short"}, // too short to be a hash
|
||||
{"abc123", "abc123"}, // less than 7 hex chars
|
||||
{"no hash here", "no hash here"},
|
||||
{"ABCDEF1234567 - upper case hash", "upper case hash"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := stripCommitHash(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripCommitHash(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- isNoiseCommit ---
|
||||
|
||||
func TestIsNoiseCommit(t *testing.T) {
|
||||
tests := []struct {
|
||||
msg string
|
||||
want bool
|
||||
}{
|
||||
{"Merge branch 'main'", true},
|
||||
{"merge branch 'develop'", true},
|
||||
{"Merge pull request #42", true},
|
||||
{"Merge remote-tracking branch 'origin/main'", true},
|
||||
{"fix login bug", false},
|
||||
{"add new feature", false},
|
||||
{"merge conflicts resolved", false}, // doesn't start with "merge branch"
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isNoiseCommit(tt.msg)
|
||||
if got != tt.want {
|
||||
t.Errorf("isNoiseCommit(%q) = %v, want %v", tt.msg, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- truncateChangelog ---
|
||||
|
||||
func TestTruncateChangelog(t *testing.T) {
|
||||
body := "## Changelog\n* abc1234 - fix A\n* def5678 - fix B\n* ghi9012 - fix C\n* jkl3456 - fix D\n"
|
||||
result := truncateChangelog(body)
|
||||
if result == "" {
|
||||
t.Error("truncateChangelog returned empty")
|
||||
}
|
||||
// Should contain max 3 entries separated by "; "
|
||||
parts := strings.Split(result, "; ")
|
||||
if len(parts) > 3 {
|
||||
t.Errorf("truncateChangelog should have at most 3 entries, got %d", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateChangelog_EmptyBody(t *testing.T) {
|
||||
if got := truncateChangelog(""); got != "" {
|
||||
t.Errorf("truncateChangelog('') = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- truncateChangelogForList ---
|
||||
|
||||
func TestTruncateChangelogForList(t *testing.T) {
|
||||
tests := []struct {
|
||||
body string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"", 40, "-"},
|
||||
{"## Changelog\n", 40, "-"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := truncateChangelogForList(tt.body, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateChangelogForList(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateChangelogForList_Truncation(t *testing.T) {
|
||||
body := "* abc1234 - a very long commit message that should be truncated eventually\n"
|
||||
result := truncateChangelogForList(body, 20)
|
||||
if len(result) > 20 {
|
||||
t.Errorf("result len = %d, want <= 20", len(result))
|
||||
}
|
||||
if !strings.HasSuffix(result, "...") {
|
||||
t.Errorf("truncated result should end with '...' , got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// --- progressBar ---
|
||||
|
||||
func TestProgressBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
percent float64
|
||||
filled int
|
||||
}{
|
||||
{0, 0},
|
||||
{50, 10},
|
||||
{100, 20},
|
||||
{150, 20}, // capped
|
||||
}
|
||||
for _, tt := range tests {
|
||||
bar := progressBar(tt.percent)
|
||||
if len(bar) != 20*len("█") && len(bar) != 20*len("░") {
|
||||
// Since multi-byte chars, just check total rune count
|
||||
runes := []rune(bar)
|
||||
if len(runes) != 20 {
|
||||
t.Errorf("progressBar(%v) rune count = %d, want 20", tt.percent, len(runes))
|
||||
}
|
||||
}
|
||||
filledCount := strings.Count(bar, "█")
|
||||
if filledCount != tt.filled {
|
||||
t.Errorf("progressBar(%v) filled = %d, want %d", tt.percent, filledCount, tt.filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- shortenHome ---
|
||||
|
||||
func TestShortenHome(t *testing.T) {
|
||||
// Non-home path should be unchanged
|
||||
got := shortenHome("/tmp/somewhere")
|
||||
if got != "/tmp/somewhere" {
|
||||
t.Errorf("shortenHome(/tmp/somewhere) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveUpgradeFormat ---
|
||||
|
||||
func TestResolveUpgradeFormat_Default(t *testing.T) {
|
||||
root := &cobra.Command{}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
child := &cobra.Command{}
|
||||
root.AddCommand(child)
|
||||
|
||||
// format not changed => should default to "table" for upgrade
|
||||
got := resolveUpgradeFormat(child)
|
||||
if got != "table" {
|
||||
t.Errorf("resolveUpgradeFormat(unchanged) = %q, want %q", got, "table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUpgradeFormat_ExplicitJSON(t *testing.T) {
|
||||
root := &cobra.Command{}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
child := &cobra.Command{}
|
||||
root.AddCommand(child)
|
||||
|
||||
// Simulate user explicitly setting format
|
||||
root.PersistentFlags().Set("format", "json")
|
||||
|
||||
got := resolveUpgradeFormat(child)
|
||||
if got != "json" {
|
||||
t.Errorf("resolveUpgradeFormat(explicit json) = %q, want %q", got, "json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUpgradeFormat_ExplicitTable(t *testing.T) {
|
||||
root := &cobra.Command{}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
child := &cobra.Command{}
|
||||
root.AddCommand(child)
|
||||
|
||||
root.PersistentFlags().Set("format", "table")
|
||||
|
||||
got := resolveUpgradeFormat(child)
|
||||
if got != "table" {
|
||||
t.Errorf("resolveUpgradeFormat(explicit table) = %q, want %q", got, "table")
|
||||
}
|
||||
}
|
||||
|
||||
// --- writeJSON ---
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
data := map[string]any{
|
||||
"version": "v1.0.6",
|
||||
"ok": true,
|
||||
}
|
||||
if err := writeJSON(&buf, data); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, `"version": "v1.0.6"`) {
|
||||
t.Errorf("output missing version: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, `"ok": true`) {
|
||||
t.Errorf("output missing ok: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- strictVerifyFile ---
|
||||
|
||||
func TestStrictVerifyFile_MatchesChecksums(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
content := []byte("valid binary content")
|
||||
os.WriteFile(filePath, content, 0644)
|
||||
|
||||
hash := computeTestSHA256(t, content)
|
||||
checksums := hash + " test.tar.gz\n"
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
|
||||
if err != nil {
|
||||
t.Errorf("expected success, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_ChecksumMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
os.WriteFile(filePath, []byte("tampered content"), 0644)
|
||||
|
||||
checksums := "0000000000000000000000000000000000000000000000000000000000000000 test.tar.gz\n"
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for checksum mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "校验失败") {
|
||||
t.Errorf("error = %q, want to contain '校验失败'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_DigestMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
os.WriteFile(filePath, []byte("tampered"), 0644)
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz",
|
||||
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for digest mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_NoChecksumInfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "test.tar.gz")
|
||||
os.WriteFile(filePath, []byte("content"), 0644)
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", "")
|
||||
if err != nil {
|
||||
t.Errorf("no checksum info should skip, not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictVerifyFile_FileNotInChecksums_FallsToDigest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "skills.zip")
|
||||
content := []byte("skills content")
|
||||
os.WriteFile(filePath, content, 0644)
|
||||
|
||||
hash := computeTestSHA256(t, content)
|
||||
// checksums.txt has entries but NOT skills.zip
|
||||
checksums := "abcdef1234567890 other-file.tar.gz\n"
|
||||
|
||||
err := strictVerifyFile("[1/5]", filePath, "skills.zip", "sha256:"+hash, checksums)
|
||||
if err != nil {
|
||||
t.Errorf("should fall through to digest and succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func computeTestSHA256(t *testing.T, data []byte) string {
|
||||
t.Helper()
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// --- newUpgradeCommand ---
|
||||
|
||||
func TestNewUpgradeCommand_Flags(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
|
||||
if cmd.Use != "upgrade" {
|
||||
t.Errorf("Use = %q, want upgrade", cmd.Use)
|
||||
}
|
||||
|
||||
expectedFlags := []string{"check", "list", "version", "rollback", "force", "skip-skills"}
|
||||
for _, name := range expectedFlags {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("missing flag: --%s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUpgradeCommand_NoArgs(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
// Simulate passing positional args - should error with cobra.NoArgs
|
||||
cmd.SetArgs([]string{"rollback"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Error("expected error for positional args (NoArgs)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
cmd.Execute()
|
||||
help := buf.String()
|
||||
|
||||
if !strings.Contains(help, "upgrade") {
|
||||
t.Error("help should contain 'upgrade'")
|
||||
}
|
||||
if !strings.Contains(help, "--check") {
|
||||
t.Error("help should contain --check")
|
||||
}
|
||||
if !strings.Contains(help, "--rollback") {
|
||||
t.Error("help should contain --rollback")
|
||||
}
|
||||
}
|
||||
|
||||
// --- isLikelyAMFIKill ---
|
||||
|
||||
func TestIsLikelyAMFIKill(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil error", nil, false},
|
||||
{"signal killed (real Go format)", errors.New("signal: killed"), true},
|
||||
{"signal kill variant", errors.New("signal: kill"), true},
|
||||
{"unrelated error", errors.New("exit status 1"), false},
|
||||
{"file not found", errors.New("no such file or directory"), false},
|
||||
{"wrapped killed in middle", errors.New("exec: signal: killed: cleanup"), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLikelyAMFIKill(tt.err); got != tt.want {
|
||||
t.Errorf("isLikelyAMFIKill(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- validateNewBinary self-heal (darwin only) ---
|
||||
//
|
||||
// On macOS, an unsigned arm64 binary is SIGKILL'd by amfid. This test verifies
|
||||
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
|
||||
// successfully re-executes the binary. We use go itself as a stand-in for the
|
||||
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
|
||||
|
||||
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("amfid SIGKILL only happens on macOS")
|
||||
}
|
||||
if _, err := exec.LookPath("codesign"); err != nil {
|
||||
t.Skip("codesign not available")
|
||||
}
|
||||
|
||||
// Build a fresh dws binary into a temp dir.
|
||||
tmpDir := t.TempDir()
|
||||
bin := filepath.Join(tmpDir, "dws-test")
|
||||
|
||||
// Locate repo root from this test file's location.
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
repoRoot := filepath.Join(wd, "..", "..")
|
||||
cmd := exec.Command("go", "build", "-o", bin, "./cmd")
|
||||
cmd.Dir = repoRoot
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("go build failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Strip signature to reproduce the unsigned state from CI cross-compilation.
|
||||
if out, err := exec.Command("codesign", "--remove-signature", bin).CombinedOutput(); err != nil {
|
||||
t.Fatalf("strip signature: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Sanity: confirm direct exec is killed.
|
||||
if _, err := tryExecVersion(bin); err == nil {
|
||||
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
|
||||
}
|
||||
|
||||
// validateNewBinary should self-heal and succeed.
|
||||
if err := validateNewBinary(bin, "dev"); err != nil {
|
||||
t.Fatalf("validateNewBinary did not recover: %v", err)
|
||||
}
|
||||
|
||||
// Verify the binary now has an ad-hoc signature.
|
||||
out, _ := exec.Command("codesign", "-dv", bin).CombinedOutput()
|
||||
if !strings.Contains(string(out), "Signature=adhoc") {
|
||||
t.Errorf("expected adhoc signature, got: %s", out)
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -13,7 +13,22 @@
|
||||
|
||||
package app
|
||||
|
||||
var version = "v1.0.2"
|
||||
var version = "dev"
|
||||
|
||||
// SetVersion overrides the version, build time and git commit strings.
|
||||
// Called by pkg/cli.SetVersion for overlay modules that inject their own
|
||||
// version info via ldflags.
|
||||
func SetVersion(v, bt, gc string) {
|
||||
if v != "" {
|
||||
version = v
|
||||
}
|
||||
if bt != "" {
|
||||
buildTime = bt
|
||||
}
|
||||
if gc != "" {
|
||||
gitCommit = gc
|
||||
}
|
||||
}
|
||||
|
||||
// Version returns the current CLI version string, including build metadata
|
||||
// when injected via ldflags (buildTime, gitCommit).
|
||||
@@ -23,3 +38,12 @@ func Version() string {
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// RawVersion returns the bare version string without build metadata.
|
||||
func RawVersion() string { return version }
|
||||
|
||||
// BuildTime returns the build timestamp injected via ldflags.
|
||||
func BuildTime() string { return buildTime }
|
||||
|
||||
// GitCommit returns the git commit hash injected via ldflags.
|
||||
func GitCommit() string { return gitCommit }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// overrideVisibleProducts temporarily installs an edition hook exposing the
|
||||
// given static product list and restores the previous hooks on cleanup.
|
||||
func overrideVisibleProducts(t *testing.T, products []string) {
|
||||
t.Helper()
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
VisibleProducts: func() []string { return products },
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
}
|
||||
|
||||
// registerPluginProduct simulates a plugin's `AppendDynamicServer` call so
|
||||
// the product ID ends up in DirectRuntimeProductIDs() without triggering
|
||||
// network discovery.
|
||||
func registerPluginProduct(t *testing.T, id, endpoint string) {
|
||||
t.Helper()
|
||||
AppendDynamicServer(market.ServerDescriptor{
|
||||
Endpoint: endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts
|
||||
// is a regression for the dws-wukong plugin-visibility bug: when an edition
|
||||
// installs a static VisibleProducts hook (Wukong returns 40 hardcoded product
|
||||
// IDs) and a plugin registers a new product via AppendDynamicServer
|
||||
// (e.g. `conference-local`), the plugin command must stay visible because the
|
||||
// dynamic registry takes precedence over the hook's static whitelist.
|
||||
func TestHideNonDirectRuntimeCommands_PluginVisibleDespiteStaticVisibleProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
bogusCmd := &cobra.Command{Use: "bogus-not-a-product"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, bogusCmd)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if calendarCmd.Hidden {
|
||||
t.Errorf("calendar (static VisibleProducts) must stay visible, got Hidden=true")
|
||||
}
|
||||
if pluginCmd.Hidden {
|
||||
t.Errorf("conference-local (plugin-registered) must stay visible, got Hidden=true")
|
||||
}
|
||||
if !bogusCmd.Hidden {
|
||||
t.Errorf("bogus-not-a-product must be hidden, got Hidden=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibleMCPRootCommands_IncludesPluginProducts asserts that the help
|
||||
// renderer surfaces plugin products in the "Discovered MCP Services" section
|
||||
// and does not misclassify them as utility commands.
|
||||
func TestVisibleMCPRootCommands_IncludesPluginProducts(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
overrideVisibleProducts(t, []string{"calendar"})
|
||||
registerPluginProduct(t, "conference-local", "stdio://plugin/conference-local")
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
calendarCmd := &cobra.Command{Use: "calendar"}
|
||||
pluginCmd := &cobra.Command{Use: "conference-local"}
|
||||
authCmd := &cobra.Command{Use: "auth"}
|
||||
root.AddCommand(calendarCmd, pluginCmd, authCmd)
|
||||
|
||||
services := visibleMCPRootCommands(root)
|
||||
if !containsCommand(services, "conference-local") {
|
||||
t.Errorf("visibleMCPRootCommands missing plugin command: %v", commandNames(services))
|
||||
}
|
||||
if !containsCommand(services, "calendar") {
|
||||
t.Errorf("visibleMCPRootCommands missing static product: %v", commandNames(services))
|
||||
}
|
||||
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
if containsCommand(utilities, "conference-local") {
|
||||
t.Errorf("visibleUtilityRootCommands must not include plugin command, got %v", commandNames(utilities))
|
||||
}
|
||||
if !containsCommand(utilities, "auth") {
|
||||
t.Errorf("visibleUtilityRootCommands must include genuine utility command, got %v", commandNames(utilities))
|
||||
}
|
||||
}
|
||||
|
||||
func containsCommand(cmds []*cobra.Command, name string) bool {
|
||||
for _, c := range cmds {
|
||||
if c.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandNames(cmds []*cobra.Command) []string {
|
||||
names := make([]string, 0, len(cmds))
|
||||
for _, c := range cmds {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestRenderRootHelpIncludesLong guards that renderRootHelp surfaces the
|
||||
// root command's Long description in `dws --help` output. The custom
|
||||
// SetHelpFunc in root_help.go replaces cobra's default help template, which
|
||||
// previously caused root.Long to be silently dropped. The production
|
||||
// root.Long carries the "use 'dws upgrade' if a command is missing or
|
||||
// failing" hint that AI agents rely on when they cannot find a suitable
|
||||
// command — if this test fails after a help-rendering change, agents will
|
||||
// silently lose that guidance.
|
||||
func TestRenderRootHelpIncludesLong(t *testing.T) {
|
||||
const sentinel = "SENTINEL-LONG-MUST-APPEAR-IN-HELP"
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Long: sentinel,
|
||||
}
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
|
||||
renderRootHelp(root)
|
||||
|
||||
if !strings.Contains(out.String(), sentinel) {
|
||||
t.Fatalf("renderRootHelp must render root.Long verbatim in --help output; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
// appConfigFile is the filename for the open-source edition's app
|
||||
// credentials store. Sibling editions get a name-suffixed file via
|
||||
// config.EditionFileName so two dws binaries sharing the same config
|
||||
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
|
||||
// credentials. See GetAppConfigPath for the path derivation contract.
|
||||
appConfigBase = "app"
|
||||
appConfigExt = ".json"
|
||||
appConfigFile = appConfigBase + appConfigExt
|
||||
)
|
||||
|
||||
// AppConfig represents the application credentials configuration.
|
||||
// This is stored in the edition-specific app config file, with the client
|
||||
// secret securely stored in keychain when present.
|
||||
type AppConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret SecretInput `json:"clientSecret"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Cached app config for performance (avoid repeated file reads).
|
||||
var (
|
||||
cachedAppConfig *AppConfig
|
||||
cachedAppConfigOnce sync.Once
|
||||
cachedAppConfigMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Cached resolved credentials (avoid repeated keychain access).
|
||||
var (
|
||||
cachedResolvedID string
|
||||
cachedResolvedSecret string
|
||||
cachedResolvedValid bool
|
||||
cachedResolvedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetAppConfigPath returns the path to the app config file for the
|
||||
// currently-active edition. The filename is partitioned by edition so that
|
||||
// two dws binaries from different editions sharing the same configDir
|
||||
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
|
||||
// other's credentials. Open-source stays on "app.json" for backwards
|
||||
// compatibility; sibling editions land on "app-<edition>.json".
|
||||
func GetAppConfigPath(configDir string) string {
|
||||
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
|
||||
}
|
||||
|
||||
// LoadAppConfig loads the app configuration from disk.
|
||||
// Returns nil, nil if the config file does not exist.
|
||||
func LoadAppConfig(configDir string) (*AppConfig, error) {
|
||||
path := GetAppConfigPath(configDir)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading app config: %w", err)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parsing app config: %w", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SaveAppConfig saves the app configuration to disk.
|
||||
// If the client secret is a plain string, it will be stored in keychain
|
||||
// and the config file will contain a reference to it.
|
||||
func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
// Store plain secret in keychain, convert to reference
|
||||
if config.ClientSecret.IsPlain() && config.ClientID != "" {
|
||||
storedRef, err := StoreSecret(config.ClientID, config.ClientSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storing client secret: %w", err)
|
||||
}
|
||||
config.ClientSecret = storedRef
|
||||
}
|
||||
|
||||
// Update timestamps
|
||||
if config.CreatedAt.IsZero() {
|
||||
config.CreatedAt = time.Now()
|
||||
}
|
||||
config.UpdatedAt = time.Now()
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling app config: %w", err)
|
||||
}
|
||||
|
||||
path := GetAppConfigPath(configDir)
|
||||
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing app config: %w", err)
|
||||
}
|
||||
cleanupLegacySiblingAppConfig(configDir, config)
|
||||
|
||||
// Update cache
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = config
|
||||
cachedAppConfigMu.Unlock()
|
||||
|
||||
// Invalidate resolved credentials cache so next access re-resolves
|
||||
cachedResolvedMu.Lock()
|
||||
cachedResolvedValid = false
|
||||
cachedResolvedID = ""
|
||||
cachedResolvedSecret = ""
|
||||
cachedResolvedMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
|
||||
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
|
||||
return
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
if legacyPath == GetAppConfigPath(configDir) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var legacy AppConfig
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return
|
||||
}
|
||||
if legacy.ClientID != config.ClientID {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAppConfig removes the app configuration and associated keychain secrets.
|
||||
func DeleteAppConfig(configDir string) error {
|
||||
// Load existing config to clean up keychain
|
||||
existing, _ := LoadAppConfig(configDir)
|
||||
if existing != nil {
|
||||
RemoveSecretStore(existing.ClientSecret)
|
||||
}
|
||||
|
||||
// Remove config file
|
||||
path := GetAppConfigPath(configDir)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing app config: %w", err)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = nil
|
||||
cachedAppConfigMu.Unlock()
|
||||
|
||||
// Clear resolved credentials cache
|
||||
cachedResolvedMu.Lock()
|
||||
cachedResolvedValid = false
|
||||
cachedResolvedID = ""
|
||||
cachedResolvedSecret = ""
|
||||
cachedResolvedMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedAppConfig returns the cached app configuration.
|
||||
// It loads from disk on first call and caches the result.
|
||||
// Returns nil if no configuration exists or loading fails.
|
||||
func GetCachedAppConfig(configDir string) *AppConfig {
|
||||
cachedAppConfigOnce.Do(func() {
|
||||
cfg, err := LoadAppConfig(configDir)
|
||||
if err == nil && cfg != nil {
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = cfg
|
||||
cachedAppConfigMu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
cachedAppConfigMu.RLock()
|
||||
defer cachedAppConfigMu.RUnlock()
|
||||
return cachedAppConfig
|
||||
}
|
||||
|
||||
// ReloadAppConfig forces a reload of the app configuration from disk.
|
||||
// This should be called after SaveAppConfig to ensure the cache is updated.
|
||||
func ReloadAppConfig(configDir string) (*AppConfig, error) {
|
||||
cfg, err := LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = cfg
|
||||
cachedAppConfigMu.Unlock()
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// HasAppConfig returns true if an app configuration file exists.
|
||||
func HasAppConfig(configDir string) bool {
|
||||
path := GetAppConfigPath(configDir)
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ResolveAppCredentials resolves the client ID and secret from the app config.
|
||||
// Results are cached to avoid repeated keychain access.
|
||||
// Returns empty strings if the config doesn't exist or resolution fails.
|
||||
func ResolveAppCredentials(configDir string) (clientID, clientSecret string) {
|
||||
// Fast path: check cache first
|
||||
cachedResolvedMu.RLock()
|
||||
if cachedResolvedValid {
|
||||
id, secret := cachedResolvedID, cachedResolvedSecret
|
||||
cachedResolvedMu.RUnlock()
|
||||
return id, secret
|
||||
}
|
||||
cachedResolvedMu.RUnlock()
|
||||
|
||||
// Slow path: load and cache
|
||||
cachedResolvedMu.Lock()
|
||||
defer cachedResolvedMu.Unlock()
|
||||
// Double-check after acquiring write lock
|
||||
if cachedResolvedValid {
|
||||
return cachedResolvedID, cachedResolvedSecret
|
||||
}
|
||||
|
||||
cfg := GetCachedAppConfig(configDir)
|
||||
if cfg != nil {
|
||||
cachedResolvedID = cfg.ClientID
|
||||
if secret, err := ResolveSecret(cfg.ClientSecret); err == nil {
|
||||
cachedResolvedSecret = secret
|
||||
}
|
||||
}
|
||||
cachedResolvedValid = true
|
||||
return cachedResolvedID, cachedResolvedSecret
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Verifies that two dws binaries from different editions sharing the same
|
||||
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
|
||||
// app.json files. Without partitioning, a sibling edition's post-login
|
||||
// persistence path could leak its pinned ClientID into the open-source
|
||||
// build by reading the shared file.
|
||||
|
||||
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
for _, name := range []string{"", "open"} {
|
||||
edition.Override(&edition.Hooks{Name: name})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", "app.json")
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
editionName string
|
||||
wantFile string
|
||||
}{
|
||||
{"wukong", "app-wukong.json"},
|
||||
{"dev", "app-dev.json"},
|
||||
{"embedded", "app-embedded.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
edition.Override(&edition.Hooks{Name: tc.editionName})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", tc.wantFile)
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
|
||||
// End-to-end invariant: when the same configDir is observed from two
|
||||
// different editions, the resulting app.json paths must NOT collide.
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
const cfg = "/tmp/shared-cfg"
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
openPath := GetAppConfigPath(cfg)
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(cfg)
|
||||
|
||||
if openPath == wukongPath {
|
||||
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
|
||||
}
|
||||
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
|
||||
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(configDir)
|
||||
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
got, err := LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig(open) error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("open edition read sibling app config: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
|
||||
t.Fatalf("sibling app config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("different legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing malformed legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("malformed legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
siblingFiles := map[string][]byte{
|
||||
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
}
|
||||
for name, data := range siblingFiles {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(open) error = %v", err)
|
||||
}
|
||||
|
||||
for name, want := range siblingFiles {
|
||||
got, err := os.ReadFile(filepath.Join(configDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// appTokenPrefix is the keychain account prefix for app-level tokens.
|
||||
appTokenPrefix = "app-token:"
|
||||
|
||||
// tokenExpiryBuffer is the buffer time before actual expiry to consider
|
||||
// the token as expired (same as user token: 5 minutes).
|
||||
tokenExpiryBuffer = 5 * time.Minute
|
||||
)
|
||||
|
||||
// AppTokenData stores the app-level access token obtained from the unified
|
||||
// POST /v1.0/oauth2/accessToken endpoint. It works for both new-style
|
||||
// (api.dingtalk.com) and legacy (oapi.dingtalk.com) APIs — the auth method
|
||||
// (header vs query param) is chosen by the caller based on the target host.
|
||||
type AppTokenData struct {
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
// Associated app credentials
|
||||
ClientID string `json:"client_id"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IsTokenValid returns true if the access token has not expired.
|
||||
func (d *AppTokenData) IsTokenValid() bool {
|
||||
if d == nil || d.AccessToken == "" {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(d.ExpiresAt.Add(-tokenExpiryBuffer))
|
||||
}
|
||||
|
||||
// SaveAppTokenData persists AppTokenData to keychain, keyed by clientID.
|
||||
func SaveAppTokenData(data *AppTokenData) error {
|
||||
if data.ClientID == "" {
|
||||
return fmt.Errorf("clientID is required for saving app token data")
|
||||
}
|
||||
data.UpdatedAt = time.Now()
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal app token data: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
for i := range jsonData {
|
||||
jsonData[i] = 0
|
||||
}
|
||||
}()
|
||||
|
||||
account := appTokenPrefix + data.ClientID
|
||||
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
|
||||
return fmt.Errorf("save app token to keychain: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAppTokenData loads AppTokenData from keychain for the given clientID.
|
||||
// Returns nil, nil if no data exists.
|
||||
func LoadAppTokenData(clientID string) (*AppTokenData, error) {
|
||||
if clientID == "" {
|
||||
return nil, fmt.Errorf("clientID is required for loading app token data")
|
||||
}
|
||||
account := appTokenPrefix + clientID
|
||||
jsonStr, err := keychain.Get(keychain.Service, account)
|
||||
if err != nil {
|
||||
return nil, nil // Not found is not an error
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var data AppTokenData
|
||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||
return nil, fmt.Errorf("parse app token data: %w", err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// DeleteAppTokenData removes AppTokenData from keychain for the given clientID.
|
||||
func DeleteAppTokenData(clientID string) error {
|
||||
if clientID == "" {
|
||||
return nil
|
||||
}
|
||||
account := appTokenPrefix + clientID
|
||||
return keychain.Remove(keychain.Service, account)
|
||||
}
|
||||
|
||||
// --- Token Fetch Function ---
|
||||
|
||||
// FetchAppToken obtains an app-level access token from the unified endpoint:
|
||||
//
|
||||
// POST https://api.dingtalk.com/v1.0/oauth2/accessToken
|
||||
// Body: {"appKey":"X","appSecret":"X"}
|
||||
// Response: {"accessToken":"xxx","expireIn":7200}
|
||||
//
|
||||
// The same token works for both api.dingtalk.com and oapi.dingtalk.com.
|
||||
func FetchAppToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) {
|
||||
body := map[string]string{
|
||||
"appKey": appKey,
|
||||
"appSecret": appSecret,
|
||||
}
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := appTokenHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("fetching app token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, fmt.Errorf("获取 app token 失败 (HTTP %d): %s", resp.StatusCode, truncateStr(string(respBody), 200))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", 0, fmt.Errorf("parsing app token response: %w", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return "", 0, fmt.Errorf("app token 响应缺少 accessToken 字段")
|
||||
}
|
||||
if result.ExpireIn <= 0 {
|
||||
result.ExpireIn = config.DefaultAccessTokenExpiry
|
||||
}
|
||||
return result.AccessToken, result.ExpireIn, nil
|
||||
}
|
||||
|
||||
// --- AppTokenProvider ---
|
||||
|
||||
// AppTokenProvider manages app-level token acquisition, caching and auto-refresh.
|
||||
type AppTokenProvider struct {
|
||||
ConfigDir string
|
||||
AppKey string
|
||||
AppSecret string
|
||||
HTTPClient *http.Client // injectable for testing; nil uses default
|
||||
}
|
||||
|
||||
// GetToken returns a valid app-level access token.
|
||||
// Tokens are cached in keychain and auto-refreshed when expired (with 5-min buffer).
|
||||
func (p *AppTokenProvider) GetToken(ctx context.Context) (string, error) {
|
||||
if p.AppKey == "" || p.AppSecret == "" {
|
||||
return "", fmt.Errorf("缺少应用凭证 (appKey/appSecret),请通过 --client-id/--client-secret 指定或先执行 dws auth login")
|
||||
}
|
||||
|
||||
// Load cached token data.
|
||||
data, err := LoadAppTokenData(p.AppKey)
|
||||
if err != nil {
|
||||
data = nil // Treat load errors as cache miss
|
||||
}
|
||||
|
||||
// Fast path: cached token is still valid.
|
||||
if data != nil && data.IsTokenValid() {
|
||||
return data.AccessToken, nil
|
||||
}
|
||||
|
||||
// Slow path: fetch a new token.
|
||||
if data == nil {
|
||||
data = &AppTokenData{ClientID: p.AppKey}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
token, expiresIn, fetchErr := FetchAppToken(ctx, p.AppKey, p.AppSecret)
|
||||
if fetchErr != nil {
|
||||
return "", fetchErr
|
||||
}
|
||||
data.AccessToken = token
|
||||
data.ExpiresAt = now.Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Persist updated token data.
|
||||
if saveErr := SaveAppTokenData(data); saveErr != nil {
|
||||
// Log but don't fail — token is still usable this time.
|
||||
// Write to stderr so we don't corrupt stdout JSON output when piped
|
||||
// into jq/grep/etc.
|
||||
fmt.Fprintf(os.Stderr, "Warning: 无法缓存 app token: %v\n", saveErr)
|
||||
}
|
||||
|
||||
return data.AccessToken, nil
|
||||
}
|
||||
|
||||
// truncateStr truncates a string to maxLen characters.
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// appTokenHTTPClient is the default HTTP client for app token operations.
|
||||
var appTokenHTTPClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mustJSONBody returns a *bytes.Buffer containing the JSON encoding of v, or fails the test.
|
||||
func mustJSONBody(t *testing.T, v any) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(v); err != nil {
|
||||
t.Fatalf("json encode: %v", err)
|
||||
}
|
||||
return &buf
|
||||
}
|
||||
|
||||
func TestAppTokenData_IsTokenValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data *AppTokenData
|
||||
want bool
|
||||
}{
|
||||
{"nil data", nil, false},
|
||||
{"empty token", &AppTokenData{}, false},
|
||||
{"expired", &AppTokenData{
|
||||
AccessToken: "tok",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Minute),
|
||||
}, false},
|
||||
{"within buffer", &AppTokenData{
|
||||
AccessToken: "tok",
|
||||
ExpiresAt: time.Now().Add(3 * time.Minute), // 3 min < 5 min buffer
|
||||
}, false},
|
||||
{"valid", &AppTokenData{
|
||||
AccessToken: "tok",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.data.IsTokenValid(); got != tt.want {
|
||||
t.Errorf("IsTokenValid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTokenData_JSONRoundTrip(t *testing.T) {
|
||||
original := &AppTokenData{
|
||||
AccessToken: "app-tok-abc",
|
||||
ExpiresAt: time.Now().Add(2 * time.Hour).Truncate(time.Second),
|
||||
ClientID: "my-app-key",
|
||||
UpdatedAt: time.Now().Truncate(time.Second),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded AppTokenData
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.AccessToken != original.AccessToken {
|
||||
t.Errorf("AccessToken = %q, want %q", decoded.AccessToken, original.AccessToken)
|
||||
}
|
||||
if decoded.ClientID != original.ClientID {
|
||||
t.Errorf("ClientID = %q, want %q", decoded.ClientID, original.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAppToken_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
var body map[string]string
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["appKey"] != "mykey" || body["appSecret"] != "mysecret" {
|
||||
t.Errorf("got body %v, want appKey=mykey, appSecret=mysecret", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "app-tok-123",
|
||||
"expireIn": 7200,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body := mustJSONBody(t, map[string]string{
|
||||
"appKey": "mykey",
|
||||
"appSecret": "mysecret",
|
||||
})
|
||||
resp, err := srv.Client().Post(srv.URL, "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result.AccessToken != "app-tok-123" {
|
||||
t.Errorf("got token %q, want app-tok-123", result.AccessToken)
|
||||
}
|
||||
if result.ExpireIn != 7200 {
|
||||
t.Errorf("got expireIn %d, want 7200", result.ExpireIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAppToken_EmptyToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "",
|
||||
"expireIn": 7200,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body := mustJSONBody(t, map[string]string{
|
||||
"appKey": "badkey",
|
||||
"appSecret": "badsecret",
|
||||
})
|
||||
resp, err := srv.Client().Post(srv.URL, "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
if result.AccessToken != "" {
|
||||
t.Errorf("expected empty accessToken, got %q", result.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppTokenProvider_GetToken_MissingCredentials(t *testing.T) {
|
||||
provider := &AppTokenProvider{
|
||||
ConfigDir: t.TempDir(),
|
||||
AppKey: "",
|
||||
AppSecret: "",
|
||||
}
|
||||
_, err := provider.GetToken(context.Background())
|
||||
if err == nil {
|
||||
t.Error("expected error for missing credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
if got := truncateStr("hello", 10); got != "hello" {
|
||||
t.Errorf("got %q, want hello", got)
|
||||
}
|
||||
if got := truncateStr("hello world", 5); got != "hello..." {
|
||||
t.Errorf("got %q, want hello...", got)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func TestClientID_RuntimeOverride(t *testing.T) {
|
||||
|
||||
func TestClientID_EnvFallback(t *testing.T) {
|
||||
SetClientID("")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
|
||||
t.Setenv("DWS_CLIENT_ID", "env-id")
|
||||
if got := ClientID(); got != "env-id" {
|
||||
t.Fatalf("expected env-id, got %s", got)
|
||||
@@ -34,9 +35,17 @@ func TestClientID_EnvFallback(t *testing.T) {
|
||||
|
||||
func TestClientID_Default(t *testing.T) {
|
||||
SetClientID("")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
if got := ClientID(); got != DefaultClientID {
|
||||
t.Fatalf("expected default, got %s", got)
|
||||
// When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string
|
||||
if strings.HasPrefix(DefaultClientID, "<") {
|
||||
if got := ClientID(); got != "" {
|
||||
t.Fatalf("expected empty string for placeholder, got %s", got)
|
||||
}
|
||||
} else {
|
||||
if got := ClientID(); got != DefaultClientID {
|
||||
t.Fatalf("expected default, got %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +60,7 @@ func TestClientSecret_RuntimeOverride(t *testing.T) {
|
||||
|
||||
func TestClientSecret_EnvFallback(t *testing.T) {
|
||||
SetClientSecret("")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
|
||||
t.Setenv("DWS_CLIENT_SECRET", "env-secret")
|
||||
if got := ClientSecret(); got != "env-secret" {
|
||||
t.Fatalf("expected env-secret, got %s", got)
|
||||
@@ -59,6 +69,7 @@ func TestClientSecret_EnvFallback(t *testing.T) {
|
||||
|
||||
func TestClientSecret_Default(t *testing.T) {
|
||||
SetClientSecret("")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
if got := ClientSecret(); got != DefaultClientSecret {
|
||||
t.Fatalf("expected default, got %s", got)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 = "DINGTALK_DWS_AGENTCODE"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func HostOwnsPATFlow() bool {
|
||||
return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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 "testing"
|
||||
|
||||
func TestClassifyDenialReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status *CLIAuthStatus
|
||||
currentChannel string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "error CHANNEL_REQUIRED",
|
||||
status: &CLIAuthStatus{
|
||||
ErrorCode: "CHANNEL_REQUIRED",
|
||||
},
|
||||
want: "channel_required",
|
||||
},
|
||||
{
|
||||
name: "error NO_AUTH",
|
||||
status: &CLIAuthStatus{
|
||||
ErrorCode: "NO_AUTH",
|
||||
},
|
||||
want: "no_auth",
|
||||
},
|
||||
{
|
||||
name: "success false or nil result → unknown",
|
||||
status: &CLIAuthStatus{
|
||||
Success: false,
|
||||
},
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "cliAuthEnabled true → no denial",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "userScope forbidden wins over channel",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "forbidden",
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"channel-a"},
|
||||
},
|
||||
},
|
||||
currentChannel: "channel-b",
|
||||
want: "user_forbidden",
|
||||
},
|
||||
{
|
||||
// Real-world case reported: user is in allowedUsers but the current
|
||||
// DWS_CHANNEL is not in allowedChannels. Reason must be channel,
|
||||
// NOT user.
|
||||
name: "user allowed but channel not in allowedChannels → channel_not_allowed",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
AllowedUsers: []string{"014566033934857460"},
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"2a4a658e467998befb7fa333c19ba2b3a3bacfa4"},
|
||||
},
|
||||
},
|
||||
currentChannel: "different-channel",
|
||||
want: "channel_not_allowed",
|
||||
},
|
||||
{
|
||||
name: "channelScope specified but current channel empty → channel_required",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"channel-a"},
|
||||
},
|
||||
},
|
||||
currentChannel: "",
|
||||
want: "channel_required",
|
||||
},
|
||||
{
|
||||
name: "channel matches allowedChannels → fall back to user denial",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
ChannelScope: "specified",
|
||||
AllowedChannels: []string{"channel-a"},
|
||||
},
|
||||
},
|
||||
currentChannel: "channel-a",
|
||||
want: "user_not_allowed",
|
||||
},
|
||||
{
|
||||
name: "only userScope=specified, no channel restriction → user_not_allowed",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{
|
||||
CLIAuthEnabled: false,
|
||||
UserScope: "specified",
|
||||
},
|
||||
},
|
||||
currentChannel: "",
|
||||
want: "user_not_allowed",
|
||||
},
|
||||
{
|
||||
name: "no user or channel restriction → cli_not_enabled",
|
||||
status: &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
},
|
||||
want: "cli_not_enabled",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := classifyDenialReason(tc.status, tc.currentChannel)
|
||||
if got != tc.want {
|
||||
t.Fatalf("classifyDenialReason() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setupMCPConfigDir creates a temp config dir with mcp_url pointing to the
|
||||
// given test server and sets DWS_CONFIG_DIR via t.Setenv.
|
||||
// NOTE: tests calling this must NOT use t.Parallel().
|
||||
func setupMCPConfigDir(t *testing.T, srvURL string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "mcp_url"), []byte(srvURL), 0o600)
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
// resetClientIDFromMCP clears the MCP-sourced flag (test helper).
|
||||
func resetClientIDFromMCP() {
|
||||
clientMu.Lock()
|
||||
defer clientMu.Unlock()
|
||||
clientIDFromMCP = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. CheckCLIAuthEnabled: interface error → fail-closed with retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_ServerError_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error from CheckCLIAuthEnabled when server returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ Server 500 → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_ConnectionRefused_FailClosed(t *testing.T) {
|
||||
configDir := setupMCPConfigDir(t, "http://127.0.0.1:1")
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
httpClient: &http.Client{Timeout: 2 * time.Second},
|
||||
}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when connection is refused, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ Connection refused → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_MalformedJSON_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{this is not valid json}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON, got nil")
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ Malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Timeout_FailClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(5 * time.Second)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
httpClient: &http.Client{Timeout: 200 * time.Millisecond},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(ctx, "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error on request timeout, got nil")
|
||||
}
|
||||
t.Logf("✅ Timeout → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. CheckCLIAuthEnabled: transient error then recovery → succeeds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !status.Success || status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatalf("expected CLIAuthEnabled=true, got %+v", status)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c)
|
||||
}
|
||||
t.Logf("✅ Transient error then success: attempts=%d, enabled=%v", calls.Load(), status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. CheckCLIAuthEnabled: normal responses (pass-through)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "good-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result == nil || status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=false")
|
||||
}
|
||||
t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. OAuth callback: simulates the fail-closed logic at the /callback level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOAuthCallback_CLIAuthError_ShowsNotEnabledPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error = fmt.Errorf("simulated network error")
|
||||
var authStatus *CLIAuthStatus
|
||||
_ = authStatus
|
||||
|
||||
// This is the exact expression used in oauth_provider.go callback:
|
||||
// cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil // false
|
||||
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when statusErr != nil")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=%v → cliAuthEnabled=%v → shows notEnabledHTML (fail-closed)", statusErr, cliAuthEnabled)
|
||||
}
|
||||
|
||||
func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
|
||||
if !cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be true when API returns enabled")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=nil, enabled=true → cliAuthEnabled=%v → shows successHTML", cliAuthEnabled)
|
||||
}
|
||||
|
||||
func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when server says disabled")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=nil, enabled=false → cliAuthEnabled=%v → shows notEnabledHTML", cliAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Device Flow: loginOnce with broken cliAuthEnabled endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
|
||||
default:
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected loginOnce to fail when CLI auth check fails, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "检查 CLI 授权状态失败") && !strings.Contains(err.Error(), "Failed to check CLI auth status") {
|
||||
t.Fatalf("unexpected error message: %s", err)
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth check error → login blocked: %s", err)
|
||||
}
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: false},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, SuperAdminPath):
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "admin1", Name: "张三"}},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected loginOnce to fail when CLI auth is disabled, got nil")
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth disabled by server → login blocked: %s", err)
|
||||
}
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DevicePollPath):
|
||||
// New terminal API: return APPROVED status
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "test-auth-code",
|
||||
},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
terminalBaseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
token, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected loginOnce to succeed, got error: %v", err)
|
||||
}
|
||||
if token.AccessToken != "test-access-token" {
|
||||
t.Fatalf("unexpected token: %s", token.AccessToken)
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth enabled → login succeeded, token=%s", token.AccessToken)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. FetchClientIDFromMCP: /cli/clientId error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFetchClientIDFromMCP_ServerError_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/clientId returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId 500 → fail-closed with retry: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_ConnectionRefused_FailClosed(t *testing.T) {
|
||||
setupMCPConfigDir(t, "http://127.0.0.1:1")
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when connection is refused, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId connection refused → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_MalformedJSON_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `not json at all`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON, got nil")
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_BusinessError_FailClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: false,
|
||||
ErrorCode: "forbidden",
|
||||
ErrorMsg: "access denied",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when server returns success=false, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "access denied") {
|
||||
t.Fatalf("error should contain server error message, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId business error → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: true,
|
||||
Result: "recovered-client-id",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
id, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if id != "recovered-client-id" {
|
||||
t.Fatalf("expected client ID 'recovered-client-id', got %q", id)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId transient then success: attempts=%d, id=%s", calls.Load(), id)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != ClientIDPath {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: true,
|
||||
Result: "my-client-id-123",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
id, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != "my-client-id-123" {
|
||||
t.Fatalf("expected 'my-client-id-123', got %q", id)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId normal success: id=%s", id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. GetSuperAdmins: /cli/superAdmin error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetSuperAdmins_ServerError_RetriesAndFails(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := GetSuperAdmins(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/superAdmin returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin 500 → retried 3 times: error=%q", err)
|
||||
}
|
||||
|
||||
func TestGetSuperAdmins_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "a1", Name: "张三"}, {StaffID: "a2", Name: "李四"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := GetSuperAdmins(context.Background(), "fake-token")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !result.Success || len(result.Result) != 2 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin transient then success: attempts=%d, admins=%v", calls.Load(), result.Result)
|
||||
}
|
||||
|
||||
func TestGetSuperAdmins_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "admin1", Name: "王五"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := GetSuperAdmins(context.Background(), "good-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result.Result) != 1 || result.Result[0].Name != "王五" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin normal success: admins=%v", result.Result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. SendCliAuthApply: /cli/sendCliAuthApply error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSendCliAuthApply_ServerError_RetriesAndFails(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := SendCliAuthApply(context.Background(), "fake-token", "admin1")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/sendCliAuthApply returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply 500 → retried 3 times: error=%q", err)
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 1 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "fake-token", "admin1")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failure, got error: %v", err)
|
||||
}
|
||||
if !result.Success || !result.Result {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if c := calls.Load(); c != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply transient then success: attempts=%d", calls.Load())
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "adminStaffId=admin123") {
|
||||
t.Errorf("missing or wrong adminStaffId param: %s", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "good-token", "admin123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Success || !result.Result {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply normal success: result=%+v", result)
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_BusinessError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{
|
||||
Success: false,
|
||||
ErrorCode: "invalid_admin",
|
||||
ErrorMsg: "admin not found",
|
||||
Result: false,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "fake-token", "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected success=false for business error")
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply business error: errorCode=%s, errorMsg=%s", result.ErrorCode, result.ErrorMsg)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetAppConfigCache clears cached app config so tests get a fresh load.
|
||||
func resetAppConfigCache() {
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = nil
|
||||
cachedAppConfigMu.Unlock()
|
||||
cachedAppConfigOnce = sync.Once{}
|
||||
|
||||
cachedResolvedMu.Lock()
|
||||
cachedResolvedValid = false
|
||||
cachedResolvedID = ""
|
||||
cachedResolvedSecret = ""
|
||||
cachedResolvedMu.Unlock()
|
||||
}
|
||||
|
||||
// ─── Issue #155: Defensive credential reset ────────────────────────────
|
||||
//
|
||||
// These tests verify that both DeviceFlowProvider and OAuthProvider always
|
||||
// reset credential state and re-fetch clientID from MCP, regardless of what
|
||||
// previous login methods left in app.json or runtime state.
|
||||
|
||||
func TestIssue155V2_OAuthLoginNoSource_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: OAuth login saved app.json WITHOUT Source field (the original bug).
|
||||
// Device flow should ignore the stale clientID, reset state, and re-fetch from MCP.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate OAuth login: saved app.json with clientId but NO Source field
|
||||
oauthAppJSON := `{"clientId":"ding-oauth-stale","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(oauthAppJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
// Verify precondition: ClientID() loads stale value from app.json
|
||||
resetAppConfigCache()
|
||||
gotID := ClientID()
|
||||
if gotID != "ding-oauth-stale" {
|
||||
t.Fatalf("precondition: ClientID() = %q, want 'ding-oauth-stale'", gotID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("precondition: IsClientIDFromMCP() should be false for app.json without Source")
|
||||
}
|
||||
|
||||
// Now create a DeviceFlowProvider — it should pick up the stale clientID
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
if provider.clientID != "ding-oauth-stale" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-oauth-stale' (from app.json)", provider.clientID)
|
||||
}
|
||||
|
||||
// Key assertion: after prepareCredentials(), the provider should have
|
||||
// cleared the stale clientID and be ready for MCP fetch.
|
||||
// We can't call Login() directly (needs real MCP server), but we can
|
||||
// verify the reset logic by calling the new method directly.
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_LegacyAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: Legacy app.json (no Source field at all) exists from an old CLI version.
|
||||
// Device flow should reset and re-fetch.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Write legacy app.json
|
||||
legacyJSON := `{"clientId":"ding-legacy-old","clientSecret":"","createdAt":"2026-01-01T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(legacyJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// Verify stale clientID was loaded
|
||||
if provider.clientID != "ding-legacy-old" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-legacy-old'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_DirectAppJson_ThenDeviceLogin_ResetsAndFetches(t *testing.T) {
|
||||
// Scenario: User previously logged in with --client-id + --client-secret (direct mode).
|
||||
// app.json has a different clientId. Device flow should reset and re-fetch from MCP.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate direct-mode app.json with clientSecret stored
|
||||
if err := SaveAppConfig(dir, &AppConfig{
|
||||
ClientID: "ding-direct-custom",
|
||||
ClientSecret: PlainSecret("some-secret"),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAppConfig error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// Verify the direct clientID was loaded
|
||||
if provider.clientID != "ding-direct-custom" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-direct-custom'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_MCPFlagAlreadySet_ThenDeviceLogin_StillResets(t *testing.T) {
|
||||
// Scenario: MCP flag is already set from a previous device login in same process.
|
||||
// Device flow should still reset and re-fetch to ensure freshness.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Simulate: MCP flag is already set from previous login
|
||||
SetClientIDFromMCP("ding-old-mcp")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
if provider.clientID != "ding-old-mcp" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-old-mcp'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear both clientID and MCP flag
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_NoAppJson_DeviceLogin_StillWorks(t *testing.T) {
|
||||
// Scenario: No app.json exists (first time login). Device flow should work normally.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewDeviceFlowProvider(dir, nil)
|
||||
|
||||
// clientID should already be empty
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("provider.clientID = %q, want empty (no app.json)", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should be a no-op but not crash
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OAuthProvider defensive reset (--force login) ─────────────────────
|
||||
|
||||
func TestIssue155V2_OAuthForceLogin_ResetsStaleCredentials(t *testing.T) {
|
||||
// Scenario: Previous login saved app.json with MCP-fetched clientID but
|
||||
// no Source marker. OAuth --force login should reset and re-fetch.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
// Simulate previous login: app.json with clientId but no Source
|
||||
staleJSON := `{"clientId":"ding-stale-oauth","clientSecret":"","createdAt":"2026-04-24T00:00:00+08:00"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, appConfigFile), []byte(staleJSON), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
|
||||
resetAppConfigCache()
|
||||
provider := NewOAuthProvider(dir, nil)
|
||||
|
||||
// Verify stale clientID was loaded
|
||||
if provider.clientID != "ding-stale-oauth" {
|
||||
t.Fatalf("provider.clientID = %q, want 'ding-stale-oauth'", provider.clientID)
|
||||
}
|
||||
|
||||
// Reset should clear it
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue155V2_OAuthForceLogin_MCPFlagSet_StillResets(t *testing.T) {
|
||||
// Scenario: MCP flag is already set. OAuth --force login should still reset.
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
SetClientIDFromMCP("ding-old-mcp-oauth")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
provider := NewOAuthProvider(dir, nil)
|
||||
|
||||
provider.resetCredentialState()
|
||||
|
||||
if provider.clientID != "" {
|
||||
t.Fatalf("after resetCredentialState: provider.clientID = %q, want empty", provider.clientID)
|
||||
}
|
||||
if IsClientIDFromMCP() {
|
||||
t.Fatal("after resetCredentialState: IsClientIDFromMCP() should be false after reset")
|
||||
}
|
||||
}
|
||||
+291
-17
@@ -26,39 +26,44 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultPollInterval is the default seconds between device token polls.
|
||||
defaultPollInterval = 5
|
||||
// The server-side Redis TTL is 10 minutes; a 2-second interval keeps the
|
||||
// user-perceived latency low while staying well within rate limits.
|
||||
defaultPollInterval = 2
|
||||
// maxPollInterval caps the polling interval to prevent DoS via slow_down.
|
||||
maxPollInterval = 30
|
||||
// maxPollTotalWait caps the total wait time for device authorization.
|
||||
maxPollTotalWait = 15 * time.Minute
|
||||
// Aligned with the server-side Redis TTL (10 minutes).
|
||||
maxPollTotalWait = 10 * time.Minute
|
||||
)
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
configDir string
|
||||
clientID string
|
||||
scope string
|
||||
baseURL string
|
||||
terminalBaseURL string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
return &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
scope: DefaultScopes,
|
||||
baseURL: DefaultDeviceBaseURL,
|
||||
logger: logger,
|
||||
Output: os.Stderr,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
configDir: configDir,
|
||||
clientID: ClientID(),
|
||||
scope: DefaultScopes,
|
||||
baseURL: DefaultDeviceBaseURL,
|
||||
terminalBaseURL: GetMCPBaseURL(),
|
||||
logger: logger,
|
||||
Output: os.Stderr,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +71,18 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
||||
p.baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
||||
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
||||
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
// SetScope overrides the OAuth scope for the device flow.
|
||||
func (p *DeviceFlowProvider) SetScope(scope string) {
|
||||
if p != nil {
|
||||
p.scope = scope
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) output() io.Writer {
|
||||
if p != nil && p.Output != nil {
|
||||
return p.Output
|
||||
@@ -80,6 +97,7 @@ type DeviceAuthResponse struct {
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
Interval int `json:"interval"`
|
||||
FlowID string `json:"flowId"`
|
||||
}
|
||||
|
||||
type DeviceTokenResponse struct {
|
||||
@@ -88,6 +106,38 @@ type DeviceTokenResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// DevicePollResponse represents the response from the terminal API poll endpoint.
|
||||
type DevicePollResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data DevicePollData `json:"data"`
|
||||
// Result is an alternate envelope some service versions return instead of
|
||||
// (or alongside) Data. Always read poll fields via EffectiveData() rather
|
||||
// than touching Data/Result directly.
|
||||
Result DevicePollData `json:"result"`
|
||||
}
|
||||
|
||||
type DevicePollData struct {
|
||||
Status string `json:"status"`
|
||||
AuthCode string `json:"authCode,omitempty"`
|
||||
FlowID string `json:"flowId,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveData normalizes terminal poll responses that may carry payload
|
||||
// fields under either `data` or `result`.
|
||||
//
|
||||
// Semantics are envelope-level rather than field-level: when Data includes a
|
||||
// non-empty status, treat Data as the authoritative payload and return it
|
||||
// unchanged; otherwise fall back to Result. This avoids mixing fields from two
|
||||
// disagreeing envelopes into a Frankenstein result.
|
||||
func (r DevicePollResponse) EffectiveData() DevicePollData {
|
||||
if r.Data.Status != "" || r.Result.Status == "" {
|
||||
return r.Data
|
||||
}
|
||||
return r.Result
|
||||
}
|
||||
|
||||
type serviceResult struct {
|
||||
Success bool `json:"success"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
@@ -95,7 +145,40 @@ type serviceResult struct {
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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).
|
||||
func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
}
|
||||
|
||||
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 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
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
tokenData, err := p.loginOnce(ctx, attempt)
|
||||
@@ -149,9 +232,92 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
|
||||
}
|
||||
|
||||
// Check if CLI auth is enabled for this organization (fail-closed: block on error)
|
||||
dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
|
||||
authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
|
||||
if authErr != nil {
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 无法检查 CLI 数据访问权限状态")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr)
|
||||
}
|
||||
denialReason := classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL"))
|
||||
if denialReason != "" {
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
switch denialReason {
|
||||
case "user_forbidden":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织已禁止所有成员使用 CLI")))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织已禁止所有成员使用 CLI"))
|
||||
case "user_not_allowed":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 您不在该组织的 CLI 授权人员范围内")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员将您加入 CLI 授权人员名单。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员"))
|
||||
case "channel_not_allowed":
|
||||
ch := os.Getenv("DWS_CHANNEL")
|
||||
_, _ = fmt.Fprintf(p.output(), dfRed(i18n.T("⚠️ 当前渠道 %s 未获得该组织授权"))+"\n", ch)
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员开通该渠道的访问权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf(i18n.T("当前渠道 %s 未获得该组织授权,请联系组织管理员"), ch)
|
||||
case "channel_required":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 当前组织已开启渠道管控")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
|
||||
case "no_auth":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("认证已失效,请执行 dws auth 重新登录"))
|
||||
default:
|
||||
// cli_not_enabled or unknown — show existing admin-apply flow
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
maxAdmins = len(admins.Result)
|
||||
}
|
||||
var adminNames []string
|
||||
for i := 0; i < maxAdmins; i++ {
|
||||
adminNames = append(adminNames, admins.Result[i].Name)
|
||||
}
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("管理员操作入口:"), config.GetDeveloperSettingsURL())
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
|
||||
}
|
||||
}
|
||||
|
||||
// Save token data with associated client ID for refresh
|
||||
tokenData.ClientID = p.clientID
|
||||
if err := SaveTokenData(p.configDir, tokenData); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
||||
}
|
||||
|
||||
// Persist app credentials (with secret) if using custom client credentials.
|
||||
// MUST run BEFORE os.Setenv below to avoid env-matching short circuit.
|
||||
oauthProvider.persistAppConfigIfNeeded()
|
||||
|
||||
// Always persist clientId to app.json so future process startups
|
||||
// can load it via ResolveAppCredentials and populate DWS_CLIENT_ID env.
|
||||
if p.clientID != "" {
|
||||
_ = os.Setenv("DWS_CLIENT_ID", p.clientID)
|
||||
if !HasAppConfig(p.configDir) {
|
||||
_ = SaveAppConfig(p.configDir, &AppConfig{ClientID: p.clientID})
|
||||
}
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
@@ -217,7 +383,92 @@ func (p *DeviceFlowProvider) pollDeviceToken(ctx context.Context, deviceCode str
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// pollDeviceStatus polls the terminal API for device authorization status.
|
||||
//
|
||||
// Note: The server returns success=false for REJECTED and EXPIRED terminal
|
||||
// states (with a valid data.Status value). These are normal business outcomes,
|
||||
// not transport errors, so we return the response to the caller and let the
|
||||
// status-switch handle them.
|
||||
func (p *DeviceFlowProvider) pollDeviceStatus(ctx context.Context, flowID string) (*DevicePollResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s%s?flowId=%s", p.terminalBaseURL, DevicePollPath, url.QueryEscape(flowID))
|
||||
body, err := p.doGet(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp DevicePollResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("解析响应失败"), err)
|
||||
}
|
||||
// REJECTED/EXPIRED carry success=false but have a valid data.Status;
|
||||
// only treat as a real server error when data.Status is empty.
|
||||
if !resp.Success && resp.Data.Status == "" {
|
||||
return nil, fmt.Errorf("%s: [%s] %s", i18n.T("服务端返回错误"), resp.Code, resp.Message)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) waitForAuthorization(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
||||
if auth.FlowID == "" {
|
||||
// Keep the pre-flowId device-code polling path for regular device flow
|
||||
// login responses that do not include terminal polling metadata.
|
||||
return p.waitForAuthorizationByDeviceCode(ctx, auth)
|
||||
}
|
||||
return p.waitForAuthorizationByFlowID(ctx, auth)
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) waitForAuthorizationByFlowID(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
||||
startTime := time.Now()
|
||||
interval := time.Duration(auth.Interval) * time.Second
|
||||
deadline := time.Duration(auth.ExpiresIn) * time.Second
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed >= maxPollTotalWait || elapsed >= deadline {
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf("%s", i18n.Tf("设备授权码已过期(%d 秒),请重试", auth.ExpiresIn))
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
|
||||
pollCount++
|
||||
elapsedSec := int(time.Since(startTime).Seconds())
|
||||
dfPrintPollStatus(p.output(), pollCount, elapsedSec)
|
||||
|
||||
pollResp, err := p.pollDeviceStatus(ctx, auth.FlowID)
|
||||
if err != nil {
|
||||
dfPrintPollResult(p.output(), "network_error", i18n.T("网络错误,继续重试..."))
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("poll error", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pollData := pollResp.EffectiveData()
|
||||
switch pollData.Status {
|
||||
case StatusApproved:
|
||||
dfPrintPollResult(p.output(), "authorized", i18n.T("授权成功!"))
|
||||
return &DeviceTokenResponse{AuthCode: pollData.AuthCode}, nil
|
||||
case StatusPending:
|
||||
dfPrintPollResult(p.output(), "pending", i18n.T("等待用户授权..."))
|
||||
case StatusRejected:
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("用户拒绝了授权请求"))
|
||||
case StatusExpired:
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("设备授权码已过期"))
|
||||
default:
|
||||
dfPrintPollResult(p.output(), "unknown", fmt.Sprintf(i18n.T("未知状态: %s"), pollData.Status))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) waitForAuthorizationByDeviceCode(ctx context.Context, auth *DeviceAuthResponse) (*DeviceTokenResponse, error) {
|
||||
startTime := time.Now()
|
||||
interval := time.Duration(auth.Interval) * time.Second
|
||||
deadline := time.Duration(auth.ExpiresIn) * time.Second
|
||||
@@ -297,6 +548,29 @@ func (p *DeviceFlowProvider) postForm(ctx context.Context, endpoint string, para
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// doGet performs an HTTP GET request and returns the response body.
|
||||
func (p *DeviceFlowProvider) doGet(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("创建请求失败"), err)
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("发送请求失败"), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("读取响应失败"), err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(body, 200))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// truncateBody returns a string of at most maxLen bytes from body, appending
|
||||
// "...(truncated)" when the content exceeds the limit. This prevents leaking
|
||||
// potentially sensitive response payloads in error messages.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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
|
||||
|
||||
// Device flow authorization status constants.
|
||||
// Shared across device_flow.go and pat_auth_retry.go to avoid maintaining
|
||||
// string literals in multiple places.
|
||||
const (
|
||||
StatusPending = "PENDING"
|
||||
StatusApproved = "APPROVED"
|
||||
StatusRejected = "REJECTED"
|
||||
StatusExpired = "EXPIRED"
|
||||
StatusCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
// ParseDeviceFlowStatus normalizes a raw status string from the device flow
|
||||
// poll response into a canonical status constant. When the server returns an
|
||||
// empty status with success=false, it falls back to StatusExpired (server
|
||||
// error / flow not found).
|
||||
func ParseDeviceFlowStatus(rawStatus string, success bool) string {
|
||||
switch rawStatus {
|
||||
case StatusApproved, StatusRejected, StatusExpired, StatusPending, StatusCancelled:
|
||||
return rawStatus
|
||||
default:
|
||||
if rawStatus == "" && !success {
|
||||
return StatusExpired
|
||||
}
|
||||
return rawStatus
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,21 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func newDeviceFlowTestLogger() *slog.Logger {
|
||||
@@ -46,6 +52,10 @@ func writeServiceResult(w http.ResponseWriter, success bool, result any, errCode
|
||||
func TestRequestDeviceCodeSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Set a test client ID
|
||||
SetClientID("test-client-id")
|
||||
t.Cleanup(func() { SetClientID("") })
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
@@ -85,22 +95,42 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// New terminal API uses GET method
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "flowId=") {
|
||||
t.Fatal("flowId query parameter should be present")
|
||||
}
|
||||
if calls.Add(1) <= 2 {
|
||||
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
|
||||
// Return PENDING status
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "final-auth-code"}, "", "")
|
||||
// Return APPROVED status with authCode
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "final-auth-code",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetBaseURL(server.URL)
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
|
||||
DeviceCode: "dc-1",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
FlowID: "test-flow-id",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForAuthorization() error = %v", err)
|
||||
@@ -113,9 +143,185 @@ func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationAcceptsResultEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("method = %s, want GET", r.Method)
|
||||
}
|
||||
if calls.Add(1) <= 2 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": map[string]string{
|
||||
"status": "APPROVED",
|
||||
"authCode": "final-auth-code",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
|
||||
FlowID: "test-flow-id-result",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForAuthorization() error = %v", err)
|
||||
}
|
||||
if resp.AuthCode != "final-auth-code" {
|
||||
t.Fatalf("auth code = %q, want final-auth-code", resp.AuthCode)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("poll calls = %d, want 3", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePollResponseEffectiveData_FallsBackToResultEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := DevicePollResponse{
|
||||
Success: true,
|
||||
Result: DevicePollData{
|
||||
Status: "APPROVED",
|
||||
AuthCode: "auth-from-result",
|
||||
FlowID: "flow-from-result",
|
||||
},
|
||||
}
|
||||
|
||||
effective := resp.EffectiveData()
|
||||
if effective.Status != "APPROVED" {
|
||||
t.Fatalf("effective.Status = %q, want APPROVED", effective.Status)
|
||||
}
|
||||
if effective.AuthCode != "auth-from-result" {
|
||||
t.Fatalf("effective.AuthCode = %q, want auth-from-result", effective.AuthCode)
|
||||
}
|
||||
if effective.FlowID != "flow-from-result" {
|
||||
t.Fatalf("effective.FlowID = %q, want flow-from-result", effective.FlowID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePollResponseEffectiveData_DataEnvelopeWinsAsWholePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := DevicePollResponse{
|
||||
Success: true,
|
||||
Data: DevicePollData{
|
||||
Status: "PENDING",
|
||||
},
|
||||
Result: DevicePollData{
|
||||
Status: "APPROVED",
|
||||
AuthCode: "auth-from-result",
|
||||
FlowID: "flow-from-result",
|
||||
},
|
||||
}
|
||||
|
||||
effective := resp.EffectiveData()
|
||||
if effective.Status != "PENDING" {
|
||||
t.Fatalf("effective.Status = %q, want PENDING", effective.Status)
|
||||
}
|
||||
if effective.AuthCode != "" {
|
||||
t.Fatalf("effective.AuthCode = %q, want empty because Data envelope wins as a whole", effective.AuthCode)
|
||||
}
|
||||
if effective.FlowID != "" {
|
||||
t.Fatalf("effective.FlowID = %q, want empty because Data envelope wins as a whole", effective.FlowID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationFallsBackToDeviceCodeWhenFlowIDMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm() error = %v", err)
|
||||
}
|
||||
if got := r.FormValue("device_code"); got != "legacy-device-code" {
|
||||
t.Fatalf("device_code = %q, want legacy-device-code", got)
|
||||
}
|
||||
if calls.Add(1) <= 2 {
|
||||
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
|
||||
return
|
||||
}
|
||||
writeServiceResult(w, true, DeviceTokenResponse{AuthCode: "legacy-auth-code"}, "", "")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
var output bytes.Buffer
|
||||
provider.Output = &output
|
||||
provider.SetBaseURL(server.URL)
|
||||
|
||||
resp, err := provider.waitForAuthorization(context.Background(), &DeviceAuthResponse{
|
||||
DeviceCode: "legacy-device-code",
|
||||
ExpiresIn: 10,
|
||||
Interval: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForAuthorization() error = %v", err)
|
||||
}
|
||||
if resp.AuthCode != "legacy-auth-code" {
|
||||
t.Fatalf("auth code = %q, want legacy-auth-code", resp.AuthCode)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("poll calls = %d, want 3", calls.Load())
|
||||
}
|
||||
if !strings.Contains(output.String(), i18n.T("等待用户授权...")) {
|
||||
t.Fatalf("expected device-code path to emit pending output, got %q", output.String())
|
||||
}
|
||||
if !strings.Contains(output.String(), i18n.T("授权成功!")) {
|
||||
t.Fatalf("expected device-code path to emit success output, got %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// New terminal API uses GET method
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]string{"status": "PENDING"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.Output = io.Discard
|
||||
provider.SetTerminalBaseURL(server.URL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
FlowID: "test-flow-id-2",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationByDeviceCodeHonorsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceResult(w, true, DeviceTokenResponse{Error: "authorization_pending"}, "", "")
|
||||
}))
|
||||
@@ -128,11 +334,89 @@ func TestWaitForAuthorizationHonorsContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "dc-2",
|
||||
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "legacy-device-code",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("waitForAuthorization() error = nil, want context cancellation")
|
||||
})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("waitForAuthorization() error = %v, want context deadline exceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationByDeviceCodeErrorStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
responses []DeviceTokenResponse
|
||||
wantErr string
|
||||
wantErrIs error
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "slow_down_then_context_cancelled",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
responses: []DeviceTokenResponse{{Error: "slow_down"}},
|
||||
wantErrIs: context.DeadlineExceeded,
|
||||
wantOutput: fmt.Sprintf(i18n.T("轮询过快,间隔增加至 %ds"), 6),
|
||||
},
|
||||
{
|
||||
name: "access_denied",
|
||||
timeout: 5 * time.Second,
|
||||
responses: []DeviceTokenResponse{{Error: "access_denied"}},
|
||||
wantErr: i18n.T("用户拒绝了授权请求"),
|
||||
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
|
||||
},
|
||||
{
|
||||
name: "expired_token",
|
||||
timeout: 5 * time.Second,
|
||||
responses: []DeviceTokenResponse{{Error: "expired_token"}},
|
||||
wantErr: i18n.T("设备授权码已过期"),
|
||||
wantOutput: fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), 1, 1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
idx := int(calls.Add(1)) - 1
|
||||
if idx >= len(tt.responses) {
|
||||
idx = len(tt.responses) - 1
|
||||
}
|
||||
writeServiceResult(w, true, tt.responses[idx], "", "")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
var output bytes.Buffer
|
||||
provider.Output = &output
|
||||
provider.SetBaseURL(server.URL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := provider.waitForAuthorization(ctx, &DeviceAuthResponse{
|
||||
DeviceCode: "legacy-device-code",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
})
|
||||
|
||||
if tt.wantErrIs != nil {
|
||||
if !errors.Is(err, tt.wantErrIs) {
|
||||
t.Fatalf("waitForAuthorization() error = %v, want %v", err, tt.wantErrIs)
|
||||
}
|
||||
} else if err == nil || err.Error() != tt.wantErr {
|
||||
t.Fatalf("waitForAuthorization() error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if !strings.Contains(output.String(), tt.wantOutput) {
|
||||
t.Fatalf("expected output to contain %q, got %q", tt.wantOutput, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+198
-5
@@ -15,9 +15,37 @@ package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_ID",
|
||||
Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppKey (DingTalk 应用凭证)",
|
||||
DefaultValue: "(内置)",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CLIENT_SECRET",
|
||||
Category: configmeta.CategoryAuth,
|
||||
Description: "OAuth AppSecret (DingTalk 应用凭证)",
|
||||
DefaultValue: "(内置)",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: "DWS_CHANNEL",
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "第三方渠道编码 (channelCode),如 Qoderwork",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
// AuthorizeURL is the DingTalk OAuth authorization page.
|
||||
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
|
||||
@@ -56,18 +84,136 @@ const (
|
||||
// DeviceGrantType is the grant_type value defined by RFC 8628.
|
||||
DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
// Terminal API base URL for developer settings page.
|
||||
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
|
||||
// DevicePollPath is the device flow polling path (used with MCP base URL).
|
||||
DevicePollPath = "/cli/oauth/device/poll"
|
||||
|
||||
// DeveloperSettingsPath is the path to the organization developer settings page.
|
||||
DeveloperSettingsPath = "/fe/old#/developerSettings"
|
||||
|
||||
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
// MCP API endpoints for CLI authorization management.
|
||||
DefaultMCPBaseURL = "https://mcp.dingtalk.com"
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
|
||||
// MCP OAuth endpoints (used when clientId is fetched from MCP).
|
||||
MCPOAuthTokenPath = "/oauth2/getToken"
|
||||
MCPRefreshTokenPath = "/oauth2/refreshToken"
|
||||
MCPRevokeTokenPath = "/oauth2/revokeToken"
|
||||
|
||||
// App-level access token endpoints (for dws api raw calls).
|
||||
|
||||
// AppAccessTokenURL is the unified app-level access token endpoint.
|
||||
// POST with {"appKey":"X","appSecret":"X"} → {"accessToken":"...","expireIn":7200}
|
||||
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
)
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
func GetTerminalBaseURL() string {
|
||||
return config.GetTerminalBaseURL()
|
||||
}
|
||||
|
||||
// GetDeveloperSettingsURL returns the full URL to the organization developer
|
||||
// settings page, derived from the terminal base URL.
|
||||
func GetDeveloperSettingsURL() string {
|
||||
return config.GetDeveloperSettingsURL()
|
||||
}
|
||||
|
||||
// GetMCPBaseURL returns the MCP base URL with priority:
|
||||
// 1. ~/.dws/mcp_url file content (for pre-release environment)
|
||||
// 2. Default value (https://mcp.dingtalk.com)
|
||||
func GetMCPBaseURL() string {
|
||||
mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url")
|
||||
if data, err := os.ReadFile(mcpURLPath); err == nil {
|
||||
if url := strings.TrimSpace(string(data)); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return DefaultMCPBaseURL
|
||||
}
|
||||
|
||||
// Runtime overrides set via CLI flags (--client-id, --client-secret).
|
||||
// These take highest priority over environment variables and defaults.
|
||||
var (
|
||||
clientMu sync.RWMutex
|
||||
runtimeClientID string
|
||||
runtimeClientSecret string
|
||||
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
|
||||
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
|
||||
clientIDFromMCP bool
|
||||
)
|
||||
|
||||
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
|
||||
func SetClientIDFromMCP(id string) {
|
||||
clientMu.Lock()
|
||||
defer clientMu.Unlock()
|
||||
runtimeClientID = id
|
||||
clientIDFromMCP = true
|
||||
}
|
||||
|
||||
// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server.
|
||||
func IsClientIDFromMCP() bool {
|
||||
clientMu.RLock()
|
||||
defer clientMu.RUnlock()
|
||||
return clientIDFromMCP || edition.Get().AuthClientFromMCP
|
||||
}
|
||||
|
||||
// GetUserAccessTokenURL returns the appropriate token exchange URL.
|
||||
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
|
||||
func GetUserAccessTokenURL() string {
|
||||
if IsClientIDFromMCP() {
|
||||
return GetMCPBaseURL() + MCPOAuthTokenPath
|
||||
}
|
||||
return UserAccessTokenURL
|
||||
}
|
||||
|
||||
// GetRefreshTokenURL returns the appropriate token refresh URL.
|
||||
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
|
||||
func GetRefreshTokenURL() string {
|
||||
if IsClientIDFromMCP() {
|
||||
return GetMCPBaseURL() + MCPRefreshTokenPath
|
||||
}
|
||||
return UserAccessTokenURL // DingTalk uses same endpoint for refresh
|
||||
}
|
||||
|
||||
// GetRevokeTokenURL returns the token revocation URL (MCP only).
|
||||
// Returns empty string if not using MCP mode.
|
||||
func GetRevokeTokenURL() string {
|
||||
if IsClientIDFromMCP() {
|
||||
return GetMCPBaseURL() + MCPRevokeTokenPath
|
||||
}
|
||||
return "" // Direct mode doesn't have revoke endpoint
|
||||
}
|
||||
|
||||
// resolveCredentialSource determines the source of the current credentials.
|
||||
// Returns one of: "flag", "env", "app", "default".
|
||||
// This is used to track where credentials came from for token refresh.
|
||||
func resolveCredentialSource() string {
|
||||
clientMu.RLock()
|
||||
hasRuntimeOverride := runtimeClientID != "" || runtimeClientSecret != ""
|
||||
clientMu.RUnlock()
|
||||
|
||||
if hasRuntimeOverride {
|
||||
return "flag"
|
||||
}
|
||||
// Check if loaded from app config
|
||||
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
|
||||
return "app"
|
||||
}
|
||||
if os.Getenv("DWS_CLIENT_ID") != "" || os.Getenv("DWS_CLIENT_SECRET") != "" {
|
||||
return "env"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
|
||||
func SetClientID(id string) {
|
||||
clientMu.Lock()
|
||||
@@ -84,8 +230,11 @@ func SetClientSecret(secret string) {
|
||||
|
||||
// ClientID returns the OAuth client ID with priority:
|
||||
// 1. Runtime override (CLI flag --client-id)
|
||||
// 2. Environment variable (DWS_CLIENT_ID)
|
||||
// 3. Default hardcoded value
|
||||
// 2. Persisted app config (from previous login)
|
||||
// 3. Environment variable (DWS_CLIENT_ID)
|
||||
// 4. Default hardcoded value (if not a placeholder)
|
||||
// Returns empty string if no valid client ID is available.
|
||||
// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login()
|
||||
func ClientID() string {
|
||||
clientMu.RLock()
|
||||
override := runtimeClientID
|
||||
@@ -93,16 +242,28 @@ func ClientID() string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
if id := edition.Get().AuthClientID; id != "" {
|
||||
return id
|
||||
}
|
||||
// Try loading from persisted app config
|
||||
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
|
||||
return id
|
||||
}
|
||||
if v := os.Getenv("DWS_CLIENT_ID"); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultClientID
|
||||
// Only return default if it's not a placeholder
|
||||
if !strings.HasPrefix(DefaultClientID, "<") {
|
||||
return DefaultClientID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ClientSecret returns the OAuth client secret with priority:
|
||||
// 1. Runtime override (CLI flag --client-secret)
|
||||
// 2. Environment variable (DWS_CLIENT_SECRET)
|
||||
// 3. Default hardcoded value
|
||||
// 2. Persisted app config (from previous login, stored in keychain)
|
||||
// 3. Environment variable (DWS_CLIENT_SECRET)
|
||||
// 4. Default hardcoded value
|
||||
func ClientSecret() string {
|
||||
clientMu.RLock()
|
||||
override := runtimeClientSecret
|
||||
@@ -110,8 +271,40 @@ func ClientSecret() string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
// Try loading from persisted app config (secret is in keychain)
|
||||
if _, secret := ResolveAppCredentials(getDefaultConfigDir()); secret != "" {
|
||||
return secret
|
||||
}
|
||||
if v := os.Getenv("DWS_CLIENT_SECRET"); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultClientSecret
|
||||
}
|
||||
|
||||
// HasValidClientSecret returns true if a valid client secret is available.
|
||||
// A valid secret is one that is not a placeholder (e.g., <YOUR_CLIENT_SECRET>).
|
||||
func HasValidClientSecret() bool {
|
||||
secret := ClientSecret()
|
||||
return secret != "" && !strings.HasPrefix(secret, "<")
|
||||
}
|
||||
|
||||
// getRuntimeCredentials returns the runtime-override credentials if set.
|
||||
// Returns empty strings if no runtime overrides were provided.
|
||||
func getRuntimeCredentials() (clientID, clientSecret string) {
|
||||
clientMu.RLock()
|
||||
defer clientMu.RUnlock()
|
||||
return runtimeClientID, runtimeClientSecret
|
||||
}
|
||||
|
||||
// getDefaultConfigDir returns the default configuration directory.
|
||||
// Priority: DWS_CONFIG_DIR env var > ~/.dws
|
||||
func getDefaultConfigDir() string {
|
||||
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".dws"
|
||||
}
|
||||
return filepath.Join(homeDir, ".dws")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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 "time"
|
||||
|
||||
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
|
||||
// instant (preserving access_token and refresh_token), and writes it back. The
|
||||
// next OAuthProvider.GetAccessToken call will see IsAccessTokenValid() == false
|
||||
// and proceed to lockedRefresh, exchanging the refresh_token for a fresh
|
||||
// access_token.
|
||||
//
|
||||
// Use this only when the server has rejected the current access_token but the
|
||||
// local expiry has not yet elapsed (zombie token scenario). It does not delete
|
||||
// any token material and is safe to call concurrently — actual refresh is
|
||||
// serialized by lockedRefresh's dual-layer locking.
|
||||
//
|
||||
// Returns the original load error when there is no usable token on disk; a
|
||||
// nil error when there is no access_token to invalidate (no-op).
|
||||
func MarkAccessTokenStale(configDir string) error {
|
||||
data, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil || data.AccessToken == "" {
|
||||
return nil
|
||||
}
|
||||
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
return SaveTokenData(configDir, data)
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const identityFile = "identity.json"
|
||||
@@ -82,8 +83,11 @@ func (id *Identity) Headers() map[string]string {
|
||||
if id.Source != "" {
|
||||
h["x-dws-source"] = id.Source
|
||||
}
|
||||
// Constant headers for MCP gateway tracking
|
||||
h["x-dingtalk-scenario-code"] = "com.dingtalk.cli"
|
||||
scenarioCode := "com.dingtalk.cli"
|
||||
if sc := edition.Get().ScenarioCode; sc != "" {
|
||||
scenarioCode = sc
|
||||
}
|
||||
h["x-dingtalk-scenario-code"] = scenarioCode
|
||||
h["x-dingtalk-source"] = "github"
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -105,3 +105,45 @@ func EnsureMigration(configDir string, logger *slog.Logger) {
|
||||
func IsMigrationDone() bool {
|
||||
return migrationDone
|
||||
}
|
||||
|
||||
// Client credential storage functions.
|
||||
// These store the clientSecret associated with a specific clientId,
|
||||
// allowing token refresh to work even if environment variables change.
|
||||
|
||||
const clientSecretPrefix = "client-secret:"
|
||||
|
||||
// SaveClientSecret stores the client secret for a specific client ID.
|
||||
// This is called during login to snapshot the credentials used.
|
||||
func SaveClientSecret(clientID, clientSecret string) error {
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return nil // Nothing to save
|
||||
}
|
||||
account := clientSecretPrefix + clientID
|
||||
if err := keychain.Set(keychain.Service, account, clientSecret); err != nil {
|
||||
return fmt.Errorf("save client secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadClientSecret retrieves the stored client secret for a specific client ID.
|
||||
// Returns empty string if not found.
|
||||
func LoadClientSecret(clientID string) string {
|
||||
if clientID == "" {
|
||||
return ""
|
||||
}
|
||||
account := clientSecretPrefix + clientID
|
||||
secret, err := keychain.Get(keychain.Service, account)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
// DeleteClientSecret removes the stored client secret for a specific client ID.
|
||||
func DeleteClientSecret(clientID string) error {
|
||||
if clientID == "" {
|
||||
return nil
|
||||
}
|
||||
account := clientSecretPrefix + clientID
|
||||
return keychain.Remove(keychain.Service, account)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
+1280
-12
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user