Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e59f9aa7a | ||
|
|
047ac54afe | ||
|
|
9a78a6494a | ||
|
|
73bf77d479 | ||
|
|
a98ae9c6cf | ||
|
|
918c73f418 | ||
|
|
ef3c2feafb | ||
|
|
0a7b6d8406 | ||
|
|
b2561388fe | ||
|
|
da30780684 | ||
|
|
96986dfbff | ||
|
|
27c3449036 | ||
|
|
e9cd8c9ad9 | ||
|
|
5856a897d1 | ||
|
|
d0787ce8ee | ||
|
|
363ca3de9b | ||
|
|
987273f32b | ||
|
|
32c1d772de | ||
|
|
39b3003e2f | ||
|
|
f423031074 | ||
|
|
2879683cad | ||
|
|
878bafe55d | ||
|
|
114c47b62d | ||
|
|
6d97bf7204 | ||
|
|
06cea56e92 | ||
|
|
1f2b992e9c | ||
|
|
43798de088 | ||
|
|
c4d8987f6c | ||
|
|
fc415919d1 | ||
|
|
125f0c8fe0 | ||
|
|
55afc656ac | ||
|
|
f6c2ce655d | ||
|
|
657d2c25e3 | ||
|
|
9f7107b6bb | ||
|
|
bfd48b6a71 | ||
|
|
d41ea586bf | ||
|
|
f77232d7c1 | ||
|
|
31faf7205b | ||
|
|
45e0423d46 | ||
|
|
1b70d8f3f2 | ||
|
|
a6f309d011 | ||
|
|
390b6115bf | ||
|
|
a6b2972a1e | ||
|
|
1e0a171ceb | ||
|
|
cb4d1c215c | ||
|
|
538754bbba | ||
|
|
c2010b912b | ||
|
|
cf8cf95087 | ||
|
|
f86d10ae63 | ||
|
|
3f3ece933b | ||
|
|
3fac462410 | ||
|
|
753866867f | ||
|
|
a62bcdf460 | ||
|
|
561525a18b | ||
|
|
e1ea573247 | ||
|
|
eb9e6be944 | ||
|
|
ec59f7b042 | ||
|
|
63c0b26cf6 | ||
|
|
5004fcd285 | ||
|
|
fc9acb9007 | ||
|
|
aa6abc5ed6 | ||
|
|
ea6fd16d11 | ||
|
|
eec64bdf35 | ||
|
|
ddad2f648c | ||
|
|
391e761b59 | ||
|
|
a15fb19fd2 | ||
|
|
70107e008f | ||
|
|
4c43108bdf | ||
|
|
5e9a920b76 | ||
|
|
15e0851e06 | ||
|
|
f1ca55c649 | ||
|
|
4440479c5c | ||
|
|
36b0528d90 | ||
|
|
a2201b4ab4 | ||
|
|
181cdf4a03 | ||
|
|
818b8b29e3 | ||
|
|
109ad13844 | ||
|
|
5ac5fcbf16 | ||
|
|
fd6bbd928e | ||
|
|
67417d3fb1 | ||
|
|
91dfc8b926 | ||
|
|
b794d802f2 | ||
|
|
e6c1dfe15c | ||
|
|
32d32cd827 | ||
|
|
a65d6f23ec | ||
|
|
a838ae75a7 | ||
|
|
238f4256d3 | ||
|
|
b83e6dc239 | ||
|
|
a842560d71 | ||
|
|
d808843f75 | ||
|
|
6623a6969d | ||
|
|
a32d7985e6 | ||
|
|
d3f8e9d712 | ||
|
|
043881e0e3 | ||
|
|
4a717bd92f | ||
|
|
604ec5f50a | ||
|
|
27296ec426 | ||
|
|
b63e1b4469 | ||
|
|
6a38a168dd | ||
|
|
9771053d81 | ||
|
|
10c0c5083e | ||
|
|
a0187b5297 | ||
|
|
81991f1c07 | ||
|
|
836670ef50 | ||
|
|
53ce0a8303 | ||
|
|
78867f3601 | ||
|
|
bba94c0092 | ||
|
|
37438659e6 | ||
|
|
3c12c835a3 | ||
|
|
389f83241f | ||
|
|
3714adc2db | ||
|
|
f37d0569a1 | ||
|
|
798b58bf3c | ||
|
|
d926bed3cc | ||
|
|
fe4a79283c | ||
|
|
5ac180d3dd | ||
|
|
35e60407d3 | ||
|
|
3ee5f13c62 | ||
|
|
e32fa1535c | ||
|
|
79b8eda3b6 | ||
|
|
ea46132cf6 | ||
|
|
be80790172 | ||
|
|
2dbbca1ec9 | ||
|
|
b214c0a06c | ||
|
|
d3087d170b | ||
|
|
49637d982e | ||
|
|
4c5f1faeb1 | ||
|
|
5833e71751 | ||
|
|
0e690fbe4e | ||
|
|
f7e8106a72 | ||
|
|
87a9b5b9be | ||
|
|
97678e6441 | ||
|
|
67090ae09f | ||
|
|
6f042f9167 | ||
|
|
78dd4aaa4b | ||
|
|
088a4d67ae | ||
|
|
81f5245c8a | ||
|
|
c4946c3eaf | ||
|
|
478dc155e8 | ||
|
|
08ecb38a42 |
@@ -1,103 +0,0 @@
|
||||
name: Auto Dev Release
|
||||
|
||||
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
|
||||
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
|
||||
#
|
||||
# 设计要点:
|
||||
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
|
||||
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
|
||||
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
|
||||
# - 不含 npm publish,所以自动发布天然是绿的。
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feat/dws-devapp
|
||||
|
||||
# 串行:两次 push 不会同时算版本号撞车
|
||||
concurrency:
|
||||
group: auto-dev-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
# ---- 质量门:测试不过就不发 ----
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Test
|
||||
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
# ---- 算下一个 dev 版本号并打 tag ----
|
||||
- name: Compute next dev version
|
||||
id: ver
|
||||
run: |
|
||||
set -eu
|
||||
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
|
||||
if [ -z "$LATEST" ]; then
|
||||
BASE="v1.0.39"; N=0
|
||||
else
|
||||
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
|
||||
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
|
||||
fi
|
||||
NEW="${BASE}-dev.$((N + 1))"
|
||||
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
||||
echo "next dev release: $NEW (prev: ${LATEST:-none})"
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
set -eu
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$V"
|
||||
git push origin "$V"
|
||||
|
||||
# ---- 自己发 release(不靠 tag 触发 release.yml)----
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post-release packaging
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
|
||||
@@ -59,7 +59,52 @@ jobs:
|
||||
run: make build
|
||||
|
||||
- name: Test with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
# The registry-first final-delivery gate validates all public commands
|
||||
# and the complete generated Catalog under the race detector. Keep the
|
||||
# package timeout aligned with the macOS race job so the Linux runner's
|
||||
# five-minute default does not expire while that gate is still making
|
||||
# progress.
|
||||
run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/...
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
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: Test macOS auth and Keychain paths with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
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: Build Windows CLI
|
||||
run: go build -o dws.exe ./cmd
|
||||
|
||||
- name: Test Windows auth and DPAPI paths
|
||||
run: |
|
||||
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: gitee-code-mirror
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,14 +35,53 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main + tags to Gitee
|
||||
- name: Push main + tags to Gitee (with README localization)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
run: |
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub)
|
||||
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
git checkout -B gitee-main origin/main
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
|
||||
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
|
||||
done
|
||||
|
||||
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
|
||||
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
|
||||
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
|
||||
SVG=".github/badges/coverage.svg"
|
||||
if [ -f "$SVG" ]; then
|
||||
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
|
||||
NUM="${PCT%\%}"; INT="${NUM%.*}"
|
||||
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
|
||||
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Multi Profile E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: multi-profile-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
multi-profile-e2e:
|
||||
name: Multi Profile E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run isolated multi-profile chain
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p .tmp-bin
|
||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
||||
{
|
||||
echo "### Multi Profile E2E"
|
||||
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
|
||||
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||
echo "- Result: passed"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload debug artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: multi-profile-e2e-debug
|
||||
path: |
|
||||
.tmp-bin/multi-profile-e2e.*/out
|
||||
.tmp-bin/multi-profile-e2e.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Publish npm release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to publish to npm (e.g. v1.0.48)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Stage npm package
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ inputs.version }}"
|
||||
semver="${version#v}"
|
||||
pkg_root="dist/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
cp build/npm/install.js "$pkg_root/install.js"
|
||||
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
|
||||
cp build/npm/README.md "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
|
||||
cp dist/dws-* "$pkg_root/assets/"
|
||||
cp dist/checksums.txt "$pkg_root/assets/"
|
||||
test -f "$pkg_root/assets/dws-skills.zip"
|
||||
cat "$pkg_root/package.json"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -5,12 +5,18 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repair_npm_version:
|
||||
description: "Only publish an existing release to npm, e.g. v1.0.48"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -28,17 +34,53 @@ 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)
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
set -euo pipefail
|
||||
RCS_VERSION="0.29.0"
|
||||
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
|
||||
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"
|
||||
printf '%s %s\n' "$RCS_ARCHIVE_SHA256" /tmp/rcodesign.tar.gz \
|
||||
| sha256sum --check --strict -
|
||||
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: Prepare Apple Developer ID certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]; then
|
||||
if [ "$GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI" ]; then
|
||||
echo "APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_PASSWORD are required for official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Developer ID secrets are unavailable; fork release will use ad-hoc signing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
umask 077
|
||||
certificate_path="$RUNNER_TEMP/dws-developer-id.p12"
|
||||
password_path="$RUNNER_TEMP/dws-developer-id-password"
|
||||
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
|
||||
printf '%s' "$APPLE_CERTIFICATE_PASSWORD" > "$password_path"
|
||||
|
||||
# Fail before packaging if the secret is corrupt or the password is wrong.
|
||||
# The exported P12 may use legacy PKCS#12 ciphers; OpenSSL 3 requires
|
||||
# -legacy to validate those containers even though rcodesign can read them.
|
||||
openssl pkcs12 -legacy -in "$certificate_path" -passin "file:$password_path" -noout
|
||||
|
||||
echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV"
|
||||
echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -51,12 +93,88 @@ jobs:
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
||||
DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
- name: Remove Apple Developer ID certificate
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id.p12"
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id-password"
|
||||
|
||||
# GoReleaser uploads the original archives to a Draft before
|
||||
# post-goreleaser.sh replaces the Darwin binaries. Re-upload every changed
|
||||
# file, verify the Draft digests, and keep it private for Apple validation.
|
||||
- name: Upload finalized signed assets to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DWS_PUBLISH_RELEASE: "false"
|
||||
run: ./scripts/release/finalize-github-release.sh
|
||||
|
||||
- name: Preserve finalized distribution files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
verify-darwin-signatures:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Download finalized Darwin assets from Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-darwin-amd64.tar.gz' \
|
||||
--pattern 'dws-darwin-arm64.tar.gz' \
|
||||
--clobber
|
||||
|
||||
- name: Verify finalized Darwin signatures with Apple codesign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
archive="dist/dws-darwin-${arch}.tar.gz"
|
||||
stage="$RUNNER_TEMP/verify-darwin-${arch}"
|
||||
mkdir -p "$stage"
|
||||
tar -xzf "$archive" -C "$stage"
|
||||
test -f "$stage/dws"
|
||||
codesign --verify --strict --verbose=4 "$stage/dws"
|
||||
codesign -dvvv "$stage/dws"
|
||||
done
|
||||
|
||||
publish-release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs:
|
||||
- release
|
||||
- verify-darwin-signatures
|
||||
runs-on: ubuntu-latest
|
||||
# Mirroring every release asset to Gitee can be slow; 30 minutes previously
|
||||
# cut the fallback upload off mid-run.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore finalized distribution files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist
|
||||
|
||||
- name: Publish verified Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
@@ -70,26 +188,98 @@ jobs:
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。
|
||||
# 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
# 预发布版本不能更新 npm latest,避免普通 npm 安装链路拿到 beta。
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
|
||||
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
|
||||
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
|
||||
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
|
||||
timeout-minutes: 20
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
|
||||
repair-npm:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.repair_npm_version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Stage npm package
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ inputs.repair_npm_version }}"
|
||||
semver="${version#v}"
|
||||
pkg_root="dist/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
cp build/npm/install.js "$pkg_root/install.js"
|
||||
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
|
||||
cp build/npm/README.md "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
|
||||
cp dist/dws-* "$pkg_root/assets/"
|
||||
cp dist/checksums.txt "$pkg_root/assets/"
|
||||
test -f "$pkg_root/assets/dws-skills.zip"
|
||||
cat "$pkg_root/package.json"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
- name: Publish stable to npm
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
@@ -42,3 +42,9 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
/auto-test/
|
||||
/eval-runs/
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
dwsbin
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@ release:
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
draft: false
|
||||
# Keep the release private until post-processing has replaced the Darwin
|
||||
# archives and verified every finalized asset digest.
|
||||
draft: true
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
mode: replace
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Gitee Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to build on Gitee, e.g. v1.0.48"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
set -eu
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip curl
|
||||
|
||||
- name: Install rcodesign
|
||||
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: Build and publish Gitee release
|
||||
env:
|
||||
VERSION: ${{ inputs.version || github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
run: ./scripts/release/build-and-publish-gitee.sh
|
||||
@@ -0,0 +1,357 @@
|
||||
# Repository Agent Guide
|
||||
|
||||
This file applies to the entire repository. Keep changes scoped, preserve
|
||||
unrelated work, and use `gofmt` for every modified Go file.
|
||||
|
||||
## Build and test
|
||||
|
||||
- Build: `go build ./cmd`
|
||||
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
|
||||
- Generate Schema assets: `go generate ./internal/cli`
|
||||
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
|
||||
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
|
||||
|
||||
Generated Schema JSON is committed. Change its source inputs and generators,
|
||||
then regenerate; do not hand-edit generated Catalog or Agent metadata files.
|
||||
`internal/cli/schema_command_registry.json` is different: it is a reviewed
|
||||
`CommandRegistry` source, not a generated snapshot. It is the single reviewed
|
||||
source of stable canonical identity,
|
||||
primary paths, aliases, and navigation. Edit it only when reviewed exposure,
|
||||
identity, primary path, or aliases change; parameter, Skill, and metadata-only
|
||||
changes must not rewrite it mechanically.
|
||||
|
||||
## Agent Schema contract
|
||||
|
||||
The Schema data flow is one way:
|
||||
|
||||
```text
|
||||
1. app.NewRootCommand()
|
||||
└─ builds the real Cobra command tree and flags
|
||||
|
||||
2. schema_command_registry.json
|
||||
+ schema_hints/metadata/<product>.json tool parameters (+ cli_path)
|
||||
└─ forms EffectiveCommandRegistry
|
||||
└─ binds exactly to real Cobra leaves and aliases
|
||||
|
||||
3. Parameter resolution
|
||||
Cobra flags
|
||||
+ schema_parameter_bindings.json
|
||||
+ metadata tool parameters
|
||||
└─ produces ParameterSpec and constraints
|
||||
|
||||
4. Agent and interface semantics
|
||||
schema_hints/selection/<product>.json (selection prose)
|
||||
+ schema_hints/metadata/<product>.json (safety/interface/runtime_gate)
|
||||
+ pinned MCP metadata
|
||||
└─ resolves Agent metadata by source precedence
|
||||
Markdown is evidence only; it is not concatenated into final prose
|
||||
|
||||
5. One typed hub
|
||||
BoundCommandRegistry
|
||||
+ ParameterSpec
|
||||
+ Agent metadata
|
||||
+ Interface metadata
|
||||
└─ resolves every command exactly once into ToolSpec
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
|
||||
6. One-way publication
|
||||
SchemaRegistry
|
||||
└─ internal/cli/schema_catalog.json
|
||||
└─ dws schema list/product/group/leaf/--all
|
||||
```
|
||||
|
||||
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
|
||||
*before* Cobra binding; after that point there is no second identity source and
|
||||
no identity precedence winner. The binder must reject a missing/non-runnable
|
||||
Cobra path, an alias collision, and any native identity annotation that
|
||||
disagrees with the effective registry. A missing native identity annotation is
|
||||
allowed because annotations are implementation-side assertions, not identity
|
||||
fallbacks.
|
||||
|
||||
The assembler resolves every bound command exactly once into one `ToolSpec`.
|
||||
Build-time gates and the snapshot serializer consume that source-resolved typed
|
||||
registry/index. Runtime projections and delivery gates consume the typed
|
||||
registry/index returned by the production snapshot loader. Neither path may
|
||||
reopen annotations, merge source records, or use a previous Catalog or other
|
||||
generated JSON as a source. `schema_catalog.json` is output-only in the
|
||||
generation graph. The production loader decoding the embedded published
|
||||
snapshot is a delivery boundary, not source resolution; it must never create or
|
||||
repair a Cobra command, flag, registry entry, or later Catalog generation.
|
||||
|
||||
This split is architecturally isomorphic to Lark's typed metadata registry,
|
||||
navigation catalog, and schema renderer. DWS intentionally preserves its
|
||||
existing flat JSON wire contract for compatibility; do not treat architectural
|
||||
alignment as permission to make an unversioned wire-format change.
|
||||
|
||||
The reviewed `CommandRegistry` is the sole source of stable command identity
|
||||
and navigation. The executable Cobra tree remains the source of truth for
|
||||
whether a CLI path exists, is runnable, and which flags it accepts. Schema
|
||||
coverage is bidirectional:
|
||||
|
||||
1. Every final `SchemaRegistry` tool, including its serialized Catalog
|
||||
projection, must resolve to an executable Cobra command.
|
||||
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
|
||||
an exact, reviewed exclusion with a non-empty reason in
|
||||
`internal/cli/schema_command_exclusions.json`.
|
||||
|
||||
Do not use prefix or wildcard exclusions: they can silently hide future
|
||||
commands. Remove an exclusion when its command enters Schema; stale, invalid,
|
||||
or duplicate exclusions must fail generation and CI.
|
||||
|
||||
When adding or changing an Agent-visible command, review all relevant inputs:
|
||||
|
||||
- `internal/cli/schema_command_registry.json` for the reviewed
|
||||
`CommandRegistry`: canonical identity, primary CLI path, aliases, and stable
|
||||
navigation. It is the identity source and is not a generated artifact.
|
||||
- `internal/cli/schema_command_registry.schema.json` is its closed,
|
||||
machine-readable editing contract. Preserve the local `$schema` reference;
|
||||
unknown fields, invalid visibility values, stale paths, and collisions fail
|
||||
Go validation and policy.
|
||||
- `internal/cli/schema_hints/metadata/<product>.json` for safety, interface,
|
||||
`runtime_gate`, and optional parameter overlays (`parameters` / `cli_path`).
|
||||
- `internal/cli/schema_hints/selection/<product>.json` for reviewed Agent
|
||||
selection prose (`agent_summary`, `use_when`, `avoid_when`, `examples`).
|
||||
- `internal/cli/schema_hints/index.json` only maps product IDs to those files.
|
||||
- Native Runtime Schema identity annotations, when present, as consistency
|
||||
assertions against `EffectiveCommandRegistry`. They must agree exactly and
|
||||
must never materialize, infer, or override registry identity.
|
||||
- Flag-to-interface property mappings and required/default semantics.
|
||||
- Generated files under `internal/cli/schema_agent_metadata/` and
|
||||
`internal/cli/schema_catalog.json` after running generation.
|
||||
|
||||
Run the reverse-completeness tests whenever the Cobra tree changes. A command
|
||||
that works through `dws <path>` but cannot be found through the matching
|
||||
`dws schema` lookup is a contract failure unless it has a reviewed exact
|
||||
exclusion.
|
||||
|
||||
Metadata parameter overlays must reference an exact public runnable Cobra leaf
|
||||
and real flags. They may override Schema description, interface-property/type
|
||||
mapping, `required`, and `required_when`; they must not create commands or
|
||||
flags, define an interface, or advertise an unknown RPC. Every authored entry
|
||||
requires `reviewed: true` and a non-empty review reason.
|
||||
|
||||
For Agent-authored metadata or selection edits:
|
||||
|
||||
1. Confirm the exact command and flag names in the current Cobra tree.
|
||||
2. Edit only the owning block (`metadata/` or `selection/`); do not mix fields.
|
||||
3. Add the smallest possible entry; do not copy generated Catalog fields into
|
||||
the input.
|
||||
4. Describe user-visible semantics in `review_reason` and parameter
|
||||
descriptions.
|
||||
5. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
proposing the change.
|
||||
|
||||
## Agent curation workflow (Schema hints)
|
||||
|
||||
Use this workflow when refreshing Agent selection prose and confirmation
|
||||
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
|
||||
`selection-review.json` or Skill Markdown into Catalog fields.
|
||||
|
||||
Human-authored inputs are split into two blocks:
|
||||
|
||||
| Block | Path | Owns |
|
||||
|---|---|---|
|
||||
| **metadata** | `internal/cli/schema_hints/metadata/<product>.json` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / `runtime_gate` / optional `parameters` |
|
||||
| **selection** | `internal/cli/schema_hints/selection/<product>.json` | `agent_summary` / `use_when` / `avoid_when` / `examples` (+ product routing) |
|
||||
|
||||
`index.json` only maps product IDs to those files. Do not mix selection fields
|
||||
into metadata files or metadata fields into selection files.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
|
||||
sibling-command routing, and outcome shape — not a restatement of the
|
||||
summary. Delivered Catalog provenance is `reviewed_explicit` from
|
||||
`selection/`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` iff the tool's
|
||||
metadata `runtime_gate != none` (for example `confirm_delete`, `typed_yes`,
|
||||
`confirm_dangerous`).
|
||||
3. **Parameter overrides** (former Manual `commands`) live on metadata tools as
|
||||
`parameters` (+ `cli_path`) and are applied into EffectiveCommandRegistry.
|
||||
|
||||
### Authoring
|
||||
|
||||
For every curated tool:
|
||||
|
||||
1. Edit `metadata/<product>.json` for safety/interface/gates/parameters.
|
||||
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
|
||||
`review_reason`, `source_refs`).
|
||||
3. Run `make generate-schema`. Do not hand-edit generated
|
||||
`schema_agent_metadata/` or `schema_catalog.json`.
|
||||
|
||||
### Pull live MCP descriptions (personal token)
|
||||
|
||||
Pinned `internal/cli/schema_mcp_metadata.json` is a sanitized baseline. Prefer
|
||||
live Schema from a logged-in personal session:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # refresh discovery / tools cache
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via `interface_ref` when CLI canonical ≠ MCP path
|
||||
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
|
||||
failure, fall back to Skill + Cobra Help + pinned MCP, and record evidence
|
||||
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
|
||||
|
||||
Precedence when sources disagree: **Runtime/Cobra > live MCP > pinned MCP >
|
||||
Skill (evidence only)**.
|
||||
|
||||
### Parallel product agents
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Hand-write selection + metadata; forbid wholesale JSON merges from review
|
||||
dumps.
|
||||
- Edit only its `metadata/<product>.json` and `selection/<product>.json`.
|
||||
- **Never** `git checkout` unrelated product files to “clean scope”.
|
||||
|
||||
### Regenerate and gates
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
./scripts/policy/check-runtime-confirmation-truth.sh
|
||||
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
|
||||
```
|
||||
|
||||
Example rules (fail generation otherwise):
|
||||
|
||||
- At most two examples per tool; no `--yes` in stored examples.
|
||||
- Examples must match live Cobra argv (path, flags, required groups).
|
||||
- No shell comments in examples.
|
||||
|
||||
After generation, spot-check Catalog: selection provenance is
|
||||
`reviewed_explicit` from `selection/`, and `user_required` count equals
|
||||
metadata `runtime_gate != none`.
|
||||
|
||||
`make generate-schema` is a full deterministic snapshot rebuild, not an
|
||||
incremental patch over the previous Catalog. It rereads every reviewed input,
|
||||
removes stale generated product metadata, and rewrites the exact metadata and
|
||||
Catalog projections. Incremental work happens only when an Agent or human
|
||||
edits selected `metadata/` or `selection/` entries; the next publication still
|
||||
recomputes all outputs. Generated files must never be read back as merge input,
|
||||
and byte guards fail generation if it changes the hint inputs or CommandRegistry.
|
||||
|
||||
Selection prose may choose a more or less restrictive recommendation. It cannot
|
||||
create a Cobra command or flag, change parameter facts, invent an
|
||||
RPC/interface, alter safety metadata, or bypass command completeness. Examples
|
||||
must use an executable primary/alias path and flags accepted by the live Cobra
|
||||
command; never add `--yes` to stored examples.
|
||||
|
||||
Every example is always checked against its real `BoundCommand`: exact path,
|
||||
accepted flags, Cobra required flags/positionals, and the effective
|
||||
`require_one_of`, `require_together`, and `mutually_exclusive` constraints must
|
||||
all pass before execution eligibility is considered. A missing required value,
|
||||
constraint failure, runtime error, or MCP resolution error is a contract bug;
|
||||
none is a valid reason to skip an example.
|
||||
|
||||
Example execution defaults to contract validation only. Runtime execution is
|
||||
opt-in: an example enters `dry_run` only when its final `ToolSpec` publishes an
|
||||
explicit reviewed dry-run capability. The test never injects `--yes`, and
|
||||
`risk`/`confirmation` values do not manufacture preview support. A narrow
|
||||
runtime precondition that cannot be derived from the typed contract may use an
|
||||
exact zero-based `example_dispositions` entry with `mode=contract_only`,
|
||||
`reviewed=true`, one of the schema-enumerated reason codes, and a concrete
|
||||
non-empty reason. Such a disposition may only narrow an explicit dry-run
|
||||
capability; it cannot turn an ordinary contract-only example into a skip.
|
||||
Duplicate, missing, and out-of-range indexes fail validation. Never catch a
|
||||
dry-run failure and dynamically downgrade it to `contract_only`.
|
||||
|
||||
Normal Go tests run the exhaustive contract gate. Run
|
||||
`make test-schema-agent-examples` to additionally execute the eligible subset
|
||||
through the real Cobra `--dry-run` path with isolated HOME and blocked proxies.
|
||||
The test reports stable `total`, `contract`, `dry_run`, `contract_only`,
|
||||
`reviewed_manual`, and per-reason counts; changing those counts requires a
|
||||
review of the corresponding typed dry-run capability or manual disposition.
|
||||
This target is also part of `make policy`.
|
||||
|
||||
Treat every tool `use_when` entry as a reviewed positive selection scenario
|
||||
whose expected result is that tool's canonical path, and every `avoid_when`
|
||||
entry as a reviewed negative scenario that must not choose that tool. The
|
||||
deterministic gate derives a typed evaluation fixture from these same fields;
|
||||
it requires exact tool coverage, a real runnable `BoundCommandRegistry`
|
||||
primary command, at least one positive and negative assertion per tool, and no
|
||||
literal contradictory expectations. It does not claim that string matching
|
||||
proves natural-language understanding.
|
||||
|
||||
Semantic selection is an explicit opt-in live-model check. Run the smoke set
|
||||
(one positive and one negative scenario per product) with
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestManualAgentSelectionArkLive -count=1`.
|
||||
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
|
||||
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
|
||||
never calls a model; its blockers remain the reproducible fixture, binding,
|
||||
example, provenance, and final-delivery facts.
|
||||
|
||||
The live evaluator sends only case IDs/scenarios plus one same-product
|
||||
candidate table; expected/forbidden assertions stay local and must never be
|
||||
included in the model prompt. Built-in Ark HTTPS bases are allowlisted. A
|
||||
different HTTPS provider requires its exact base in
|
||||
`DWS_AGENT_SELECTION_ALLOWED_BASE_URLS`; plaintext HTTP is accepted only for a
|
||||
loopback test server so API credentials are never sent to an arbitrary clear
|
||||
text endpoint.
|
||||
|
||||
## Safety metadata
|
||||
|
||||
Parameter and safety resolution is mostly source-precedence based and
|
||||
value-neutral: do not choose a winner because one value looks stricter. A
|
||||
higher-priority reviewed metadata/explicit source may intentionally raise or
|
||||
lower description, mapping, `effect`, `risk`, `confirmation`, or `idempotency`.
|
||||
Preserve all candidates and the selected source in provenance, and fail
|
||||
same-precedence conflicts rather than silently merging them.
|
||||
|
||||
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
|
||||
final Agent projection must keep `required=true` and cannot be lowered by
|
||||
manual/hint overlays. Overlays may still raise an optional flag to required.
|
||||
`cli_required` continues to mirror the executable Cobra marker.
|
||||
|
||||
For command text, reviewed `ToolSchemaHint` wins first, then command-specific
|
||||
Cobra Help, then MCP metadata. Generic RPC prose may remain an unselected
|
||||
provenance candidate (and parameter-level `interface_description`); it must not
|
||||
overwrite a specialized leaf's title or description.
|
||||
|
||||
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
|
||||
winner value must exactly equal the delivered value. Checking only source,
|
||||
count, presence, or hash is not a sufficient final-delivery invariant.
|
||||
|
||||
The same resolved `ToolSpec` must drive every projection. The full leaf payload
|
||||
must equal the corresponding tool in `schema --all` and the full Catalog tool.
|
||||
Overview/product/group summaries and Catalog summaries must equal
|
||||
`ToolSpec.ToSummaryPayload()`. An alias lookup may change only the view fields
|
||||
`cli_path` and `is_alias`; it must not re-resolve or mutate the command
|
||||
contract.
|
||||
|
||||
This build-time rule is distinct from runtime drift handling. If shipped Help
|
||||
and leaf Schema disagree, pass only flags accepted by Cobra. For conflicting
|
||||
safety information, do not silently take the less restrictive behavior: use
|
||||
the safer interpretation or stop and report the contract drift.
|
||||
|
||||
Do not infer one safety field from another. In particular, `effect=destructive`
|
||||
or `risk=high` does not mechanically rewrite `confirmation`; the final
|
||||
precedence winner for each field is authoritative. When
|
||||
`confirmation=user_required`, obtain confirmation before adding `--yes`.
|
||||
Keep CLI confirmation behavior and Schema metadata consistent, and add a
|
||||
semantic regression test through the final embedded loader/query delivery
|
||||
path; a generator unit test or JSON count alone is insufficient.
|
||||
|
||||
## Current Schema boundaries
|
||||
|
||||
- `schema list` remains a progressive overview. `schema --all` is the stable
|
||||
full-export contract: every final `SchemaIndex` tool must contain its
|
||||
complete leaf parameters, constraints, and safety semantics, including an empty
|
||||
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||
compatibility baseline and fail rather than silently emitting a partial
|
||||
export.
|
||||
- `schema --all` is not normal command discovery. Use overview -> product/group
|
||||
-> leaf for routine Agent work. `--compact` is supported for context-saving
|
||||
projections, but a compact full export is not a complete compatibility
|
||||
baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A leaf Schema defines Agent selection, parameter mapping
|
||||
and constraints, and safety/confirmation semantics. A conflict is contract
|
||||
drift, not permission to guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
+204
@@ -6,6 +6,210 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.52] - 2026-07-14
|
||||
|
||||
This release seals the `v1.0.52` line with personal event subscriptions, a deterministic 22-product Agent command catalog, local user-operation auditing, expanded Open product commands, safer macOS credentials and release signing, and more reliable Connect and IM delivery.
|
||||
|
||||
### Added
|
||||
|
||||
- **Personal event subscriptions** (#589) — adds `dws event list/schema/consume/status/stop` for user @ mentions, selected one-to-one chats, and selected group chats. `consume` can create or reuse a personal subscription, multiple local consumers share one bus while keeping outputs isolated by event type and subscription, and the mono/multi event Skills ship with the binary.
|
||||
- **Open product command capabilities** (#608) — adds Sheet table, pivot-table, and gridline commands; Chat message favorites; Drive statistics and shortcuts; and Doc comment update/delete, with matching mono/multi Skill documentation and command-contract coverage.
|
||||
- **Local user-operation audit log** (#555) — operations executed through `dws` now produce redacted daily JSONL records with actor, command and endpoint, result or error category, duration, CLI/platform metadata, and a SHA-256 previous-hash chain for tamper evidence. Writers coordinate through a cross-process file lock and rotate logs safely; `dws audit tail` inspects recent records, `dws audit export` emits date-filtered JSONL or CSV, and `dws audit verify` reports the first broken link in a file's hash chain.
|
||||
- **Stable Agent command catalog** (#598) — `dws schema` now ships a deterministic 22-product / 564-tool catalog generated from the executable Cobra tree, with progressive product/group/leaf queries, complete parameter contracts, reviewed command identity and aliases, safety/confirmation metadata, field provenance, and final-delivery completeness/drift gates. The catalog is embedded at build time and does not require runtime MCP `tools/list` discovery.
|
||||
- **Reviewed Schema for local commands** (#598, #609) — `event consume/list/schema/status/stop` and `audit export/tail/verify` enter the reviewed `CommandRegistry`, bind to the real Cobra tree at generation time, and ship through the same typed `ToolSpec` and embedded Catalog path as public MCP-backed commands. Leaf, group, product, and `--all` queries are projections of that single delivered model.
|
||||
- **Safe macOS Keychain → file-DEK migration** (#597) — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`event consume` AI-subprocess contract** (#609) — emits a fixed ready line and a final controlled-exit summary, supports parent-pipe stdin EOF as graceful shutdown, forwards `--profile` to the detached bus, surfaces bus startup errors, and cleans up subscriptions according to ownership so orchestrators can drive event streams without sleeps or leaked server-side subscriptions.
|
||||
- **Wukong IM read-result parity** (#618) — `chat message list` preserves quoted merged-forward and image context; message-search entitlement failures retain the server-provided friendly hint and action URL; and `ding message list` exposes each DING's content alongside its ID and status.
|
||||
- **Developer ID signing for official macOS archives** (#605) — official releases now require both Darwin archives to be signed with the configured Apple Developer ID certificate, timestamp, and hardened runtime. The release job validates credentials and signatures and fails closed instead of silently publishing ad-hoc-signed official binaries.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Smart-category mappings and runtime network diagnostics** (#591) — `chat category create-smart` now maps category names, group-name keywords, and member OpenDingTalk IDs to the live MCP contract, rejects blank or empty supplied values locally, and reports runtime `tools/call` connection failures as actionable API/network errors instead of internal discovery failures.
|
||||
- **Connect daemon restart lifecycle** (#599) — pins the Stream SDK reconnect-race fix, snapshots the running executable before detaching, uses a real 30-second keepalive, and manages each worker as its own Unix process group so launcher cleanup or worker panics no longer cause restart loops or orphan local-agent processes.
|
||||
- **Complex Connect messages and attachments** (#606, #612) — rich-text messages retain all embedded pictures in order, queued turns keep every pending attachment, and unknown or future callback shapes reach each Agent backend with their message type and raw JSON instead of being discarded. Attachment recovery is locator-based, nested `chatRecord` pictures/audio/video/files can be recovered from message APIs after Stream ACK, and OpenCode uses a full-duration storyboard for large videos to avoid base64 OOMs while preserving the original download for the turn.
|
||||
- **macOS auth survives Keychain mode changes** (#597) — credential reads try existing compatible DEKs without creating key material, updates preserve the DEK that decrypted existing ciphertext, unreadable slots fail closed before token exchange, profile slots use the canonical auth backend, and `auth status` reports ciphertext/key mismatches instead of treating them as ordinary logout. Dedicated macOS race and Windows DPAPI coverage protect the cross-platform paths.
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
### Added
|
||||
|
||||
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
|
||||
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
|
||||
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
|
||||
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
|
||||
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
|
||||
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
|
||||
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
|
||||
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
|
||||
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
|
||||
|
||||
## [1.0.50] - 2026-07-08
|
||||
|
||||
This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing.
|
||||
- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match.
|
||||
- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`.
|
||||
- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics.
|
||||
|
||||
## [1.0.49] - 2026-07-08
|
||||
|
||||
This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Real-machine QA fixes across CLI commands** (#572) — `aitable chart/dashboard share update --enabled` now takes a string so `--enabled false` disables; `chat conversation-info --user` resolves openDingTalkId and registers `--id/--conversation-id/--chat` aliases; `chat list-all-conversations --limit` is capped at 100 and rejects larger values; custom-robot webhook failures surface `errcode` instead of masquerading as success; `contact` registers `--dept/--depts` as the primary flags so the documented spelling actually works; `sheet media-upload` and `sheet export` emit clean JSON under `--format json` (progress lines no longer leak); `wiki node create --type` enum is corrected (drops unsupported `asheet`, adds `axls/able/appt/adraw/amind`); `ding message list --type` defaults to `ALL` since the server rejects empty type.
|
||||
- **Helper script fixes (mono and multi)** (#572) — aitable import/export flag names and the tableId regex (7-char default tables were rejected); mail search `--limit`, contact dept response keys (`deptList`/`deptUserList`) and `userInfo` nesting; `attendance_my_record` whoami compatibility; `calendar_schedule_meeting` event-id unwrapping; `drive_tree_list` recursion via `fileId`; report scripts migrated off the deprecated `report list`/`report detail`.
|
||||
- **Skill docs sync (mono and multi)** (#572) — command indexes, flag names, enums, return-structure keys and cross-product intent routing are re-aligned to real-machine behavior across all products. Genuinely server-side limitations (permission gates, org-level restrictions, unregistered tool keys) are annotated instead of code-patched, and the cross-cutting hazards (`success` always true, `--jq`/`--fields` currently no-op) are documented.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release pipeline unblocks npm publish from Gitee mirror** (#570) — the Release workflow now publishes to npm before touching the Gitee mirror, so Gitee upload issues cannot block `npm/latest`. GitHub→Gitee attachment upload is disabled by default (unreliable from US runners) and only runs when `ENABLE_GITEE_UPLOAD_FALLBACK=true`; the legacy upload fallback path is guarded with timeout and retry so it fails fast when re-enabled.
|
||||
- **Repair modes for release republish** (#570) — the Release workflow gains a repair input and a standalone npm-only repair workflow, used to republish an existing release to npm without re-running the full pipeline.
|
||||
|
||||
## [1.0.48] - 2026-07-07
|
||||
|
||||
This release promotes the sealed **remove-discovery delivery** from the beta line to the stable `v1.0.48` package. It removes dynamic service discovery from the open-edition runtime, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior on the stable upgrade track.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Remove-discovery delivery is now formal/stable** — the beta validation line is ready to cut as `v1.0.48`; normal stable channels (`dws upgrade`, GitHub `releases/latest`, install scripts, and npm `latest`) should receive this release after the official tag is published.
|
||||
- **Static endpoint runtime sealed for stable delivery** — the open edition no longer depends on dynamic service discovery at runtime, while preserving legacy command compatibility aliases and the synced help/skill surface from the beta.
|
||||
- **`contact label` is restored as real wukong-compatible functionality** — `dws contact label list/get/list-members` now call `get_org_labels`, `search_label_by_name`, and `get_label_members_by_labelId`; `contact role` remains an alias, and the common top-level compatibility entries (`contact search/find/list/get/self/me/whoami/get-self`) now dispatch to real user/dept/label tools where unambiguous.
|
||||
- **Skill docs match the sealed command surface** — contact docs again describe the real `contact label` three-step role lookup flow; video-conference start/invite/share flows remain explicitly unsupported and point users to the DingTalk client.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`calendar event list --dry-run` no longer executes the real list call** — the sorted event-list wrapper now respects dry-run and prints the `list_calendar_events` preview instead of calling the backend.
|
||||
- **`chat file upload` is downlined** — the hidden compatibility entry now returns a clear downline message and never calls `chat/upload_conversation_file_by_url`; the supported file path remains `chat message send --msg-type file --file-path`.
|
||||
- **Optional plugin version validation no longer pollutes every command** — incompatible local plugins such as conference are skipped at debug level during command-tree construction instead of printing a WARN on unrelated commands.
|
||||
- **PR #45 review follow-ups are folded into the release** — doc version rollback pagination now unwraps nested result/content/data envelopes for `nextCursor`, mail helper scripts handle `{result:{emailAccounts:[...]}}`, and the generated attendance `.xlsx` fixture is removed from the skill scripts.
|
||||
|
||||
### Tests
|
||||
|
||||
- **Command-surface regression tests** — root-command tests now cover real `contact label`/`role` dry-runs, hidden top-level contact compatibility entries, `chat file upload` downline behavior, and `calendar event list --dry-run`.
|
||||
- **Release hygiene tests** — skill markdown policy still blocks unsupported conference routes, plugin loader tests assert optional validation failures stay quiet at WARN level, and doc version cursor extraction has nested-envelope coverage.
|
||||
|
||||
## [1.0.47] - 2026-07-05
|
||||
|
||||
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dev connect list`** — PM2-style colored table enumerating all local connectors with state (healthy / degraded / down / not_running), PID, channel, and uptime.
|
||||
- **`dev connect status`** — panel view with heartbeat, last recv timestamp, session webhook age, and `--json` for external monitoring.
|
||||
- **`dev connect restart`** — restarts a daemon via persisted `daemon-state.json` (unified-app-id credential fetch, no local secret storage).
|
||||
- **`dev connect stop`** — graceful SIGTERM shutdown releasing the single-instance lock and Stream connection.
|
||||
- **Health watchdog** — background goroutine writes `heartbeat.json`; `status`/`list` derive state from heartbeat freshness + process liveness + pid-reuse detection.
|
||||
- **`--alwayson` flag** — opt-in auto-restart: supervisor relaunches the worker on crash (requires `--daemon`).
|
||||
- **`--notify-staff-id`** — state-change notifications (start / stop / crash) sent as DingTalk messages to the specified staffId.
|
||||
- **`--unified-app-id` credential flow for `dev connect`** — fetches clientId/clientSecret at startup via `dev app credentials get`, keeping secrets off the command line and out of `daemon-state.json`.
|
||||
- **API-sent file download** (`feat(connect): download API-sent files via storage v2 API`) — file messages sent via `dws chat message send --msg-type file --dentry-id --space-id` are now downloaded by the connector through the storage v2 `getDownloadInfo` API (dentryId + spaceId → presigned URL → local temp file), so file-based Q&A works regardless of how the file was sent.
|
||||
- **`--at-open-dingtalk-ids` for `chat message send-by-bot`** — @-mention bots or cross-org users by openDingTalkId in group messages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bot-to-bot @-mention send side** — `atOpendingtalkIds` (the server's lowercase spelling) is now used instead of the camelCase `atOpenDingTalkIds` which was silently ignored. The unnecessary `openDingTalkId → userId` reverse lookup (always failed for bots) is removed; the id is forwarded verbatim.
|
||||
- **Bot-to-bot @-mention receive side** — `interactiveCard` messages (how DingTalk delivers a bot @-mentioning another bot) are now parsed: `extractInteractiveCardText` flattens `cardContent[].children[].value` leaves and strips the leading @-mention by leaf boundary. The `emotion/reply` reaction (which 500s on bot-sent cards) is skipped for `interactiveCard` turns.
|
||||
- **Markdown/richText body extraction** — `extractCallbackText` gains a `cardContent` fallback so structured-text messages are no longer silently dropped.
|
||||
- **Send-by-bot @ chip rendering** — `<@id>` placeholders in the markdown body are rewritten to `@id` for both userIds and openDingTalkIds so the mention chip renders in all cases.
|
||||
- **Connector retry on transient network errors** — `sendBySession` retries on transient failures instead of dropping the reply.
|
||||
- **Orphan worker cleanup & watchdog deadlock** — stale workers from a crashed supervisor are detected and cleaned; a channel-capacity fix prevents the watchdog from blocking.
|
||||
- **Idle connector false-down** — heartbeat ticker now advances `updatedUnix` so a connector with no inbound traffic is not marked degraded.
|
||||
- **FD limit check** — `checkFDLimit` split into platform files for Windows cross-compilation.
|
||||
- **Default agent timeout removed** — no timeout by default (was incorrectly defaulting to a low value).
|
||||
- **keepAlive shortened to 30 µs** — aligns with Stream SDK expectations; adds `ulimit` check for multi-agent stability.
|
||||
|
||||
## [1.0.46] - 2026-07-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT agentCode grants no longer split from follow-up command checks** (`internal/auth/agent_code_detect.go`, `internal/app/runner.go`, `internal/pat/chmod_test.go`) — explicit `DINGTALK_DWS_AGENTCODE` declarations are now forwarded verbatim as the common cross-host contract, and unknown hosts no longer synthesize `custom` into `x-dingtalk-dws-agent-code` / `x-dws-agent-instance-id`. `pat chmod --agentCode` remains the highest-priority grant target and still wins over the env fallback.
|
||||
|
||||
## [1.0.45] - 2026-06-29
|
||||
|
||||
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
|
||||
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
|
||||
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
|
||||
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
|
||||
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
|
||||
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
|
||||
|
||||
## [1.0.44] - 2026-06-28
|
||||
|
||||
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
|
||||
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
|
||||
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
|
||||
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
|
||||
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
|
||||
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
|
||||
|
||||
## [1.0.43] - 2026-06-26
|
||||
|
||||
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
|
||||
|
||||
### Added
|
||||
|
||||
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
|
||||
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
|
||||
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
|
||||
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
|
||||
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
|
||||
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
|
||||
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
|
||||
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
|
||||
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
|
||||
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
|
||||
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
|
||||
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
|
||||
|
||||
## [1.0.42] - 2026-06-25
|
||||
|
||||
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
GO ?= go
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -10,7 +10,11 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make policy - Run open-source asset and command-surface checks\n"
|
||||
@printf " make policy - Run open-source asset and Schema registry checks\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
|
||||
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
|
||||
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
|
||||
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
|
||||
@printf " make release - Build and publish a release via goreleaser\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
@@ -32,11 +36,54 @@ fmt:
|
||||
|
||||
policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-schema-command-registry.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
@./scripts/policy/check-generated-drift.sh
|
||||
@./scripts/policy/check-schema-catalog.sh
|
||||
@./scripts/policy/check-schema-binary.sh
|
||||
@$(MAKE) test-schema-agent-examples
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
|
||||
|
||||
generate-schema:
|
||||
@set -e; \
|
||||
registry_guard=$$(mktemp); \
|
||||
metadata_guard=$$(mktemp -d); \
|
||||
selection_guard=$$(mktemp -d); \
|
||||
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
|
||||
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
|
||||
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/selection "$$selection_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/selection' >&2; \
|
||||
exit 1; \
|
||||
}
|
||||
|
||||
generate-schema-agent-metadata:
|
||||
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
|
||||
-root . \
|
||||
-registry internal/cli/schema_command_registry.json \
|
||||
-output-dir internal/cli/schema_agent_metadata \
|
||||
-audit-output internal/cli/schema_agent_metadata_audit.json
|
||||
|
||||
generate-schema-catalog:
|
||||
$(GO) run -a ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output internal/cli/schema_catalog.json
|
||||
|
||||
package:
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -109,6 +109,10 @@ go build -o dws ./cmd # build to current directory
|
||||
cp dws ~/.local/bin/ # install to PATH
|
||||
```
|
||||
|
||||
Static endpoint data is generated from the Wukong baseline and committed in this
|
||||
repository under `internal/syncdata`, so source builds do not require a sibling
|
||||
data checkout.
|
||||
|
||||
> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
|
||||
|
||||
</details>
|
||||
@@ -135,6 +139,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
|
||||
|
||||
**3. Skills only (Gitee mirror):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -144,12 +156,18 @@ dws has built-in self-upgrade capability. Updates are pulled directly from [GitH
|
||||
```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 --list # list stable release versions
|
||||
dws upgrade --beta # upgrade to the latest beta pre-release
|
||||
dws upgrade --check --beta # check the beta track without installing
|
||||
dws upgrade --list --beta # list beta pre-release versions
|
||||
dws upgrade --version v1.0.7 # upgrade to a specific version
|
||||
dws upgrade --version v1.0.8-beta.1 # upgrade to a specific beta version
|
||||
dws upgrade --rollback # rollback to the previous version
|
||||
dws upgrade -y # skip confirmation prompt
|
||||
```
|
||||
|
||||
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
@@ -163,8 +181,9 @@ A backup of the current version is automatically created before each upgrade. Us
|
||||
| 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`) |
|
||||
| `--list` | List available stable release versions with changelogs |
|
||||
| `--beta` | Use the beta pre-release track for `upgrade`, `--check`, or `--list` |
|
||||
| `--version` | Upgrade to a specific version (e.g. `v1.0.7` or `v1.0.8-beta.1`) |
|
||||
| `--rollback` | Rollback to the previous backed-up version |
|
||||
| `--force` | Force reinstall even if already on the latest version |
|
||||
| `--skip-skills` | Skip skill package update |
|
||||
@@ -225,6 +244,32 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Multiple organizations (profiles)</strong></summary>
|
||||
|
||||
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
|
||||
|
||||
```bash
|
||||
dws auth login # log in to another org → adds a profile (first login becomes the primary)
|
||||
dws profile list # list logged-in orgs (primary / current marker, status)
|
||||
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
|
||||
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
|
||||
```
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
|
||||
|
||||
@@ -276,30 +321,37 @@ dws contact user search --query "engineering" --dry-run
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### Schema Discovery
|
||||
### Command Help and Schema
|
||||
|
||||
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
|
||||
Use Cobra help and Schema for different parts of the command contract:
|
||||
|
||||
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
|
||||
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
|
||||
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
|
||||
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
|
||||
|
||||
```bash
|
||||
# Step 1: Discover all available products
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
# Confirm that the command exists and inspect accepted flags
|
||||
dws aitable record query --help
|
||||
|
||||
# Step 2: Inspect target tool's parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
|
||||
# Optional: inspect DingTalk authorization metadata for PAT planning
|
||||
dws schema aitable.query_records --jq '.tool.auth'
|
||||
|
||||
# Step 3: Construct the correct call
|
||||
# Execute the real business query
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
Shared reviewed inputs for Schema generation live separately under `internal/cli/schema_hints/`. They are not Agent Skills and are excluded from binaries and release skill bundles.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
@@ -309,6 +361,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
**Switching or re-installing with `dws skill setup`:**
|
||||
|
||||
@@ -372,6 +426,51 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
|
||||
|
||||
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
|
||||
|
||||
For an event-focused installation, use the official convenience installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
|
||||
| Shared connection | Consumers for the same user share one local bus and cloud connection |
|
||||
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
|
||||
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
|
||||
|
||||
@@ -453,7 +552,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -462,14 +561,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
|
||||
```bash
|
||||
dws schema # list all products and tools
|
||||
dws schema aitable.query_records # view parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.required' # view required fields
|
||||
dws schema aitable.query_records --jq '.tool.auth' # view authorization metadata
|
||||
dws schema --jq '.products[].id' # extract all product IDs
|
||||
dws schema aitable # discover product commands
|
||||
dws schema "aitable record query" # view the selected leaf contract
|
||||
dws schema "aitable record query" --jq '.tool.required' # view required fields
|
||||
dws schema --all # full export for CI/audit/baselines
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -500,9 +598,14 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
dws dev connect --channel auto --unified-app-id <unifiedAppId>
|
||||
```
|
||||
|
||||
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
|
||||
> so the secret never appears in argv (`ps` / journald / shell history). The
|
||||
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
|
||||
> the CLI will warn you.
|
||||
|
||||
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
|
||||
|
||||
| Command | Effect |
|
||||
@@ -514,29 +617,29 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user |
|
||||
| Chat / IM | `chat` (alias `im`) | 65 | `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` | 17 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments |
|
||||
| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, 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` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get |
|
||||
| AI Tables | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | 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`); datasheet forms; templates |
|
||||
| Doc | `doc` | 28 | `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` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| 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 |
|
||||
| Service | Command | Capabilities |
|
||||
|---------|---------|--------------|
|
||||
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
|
||||
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
|
||||
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
|
||||
| Todo | `todo` | Create / list / update / complete tasks and comments |
|
||||
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
|
||||
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
|
||||
| Ding | `ding` | Send / recall DING messages |
|
||||
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
|
||||
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
|
||||
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
|
||||
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
|
||||
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
|
||||
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
|
||||
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
|
||||
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
|
||||
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
|
||||
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
|
||||
| Live | `live` | List my live streams |
|
||||
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
|
||||
|
||||
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
@@ -595,7 +698,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
- [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
|
||||
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
|
||||
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
|
||||
+142
-43
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -110,6 +110,7 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
```
|
||||
|
||||
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
|
||||
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -135,6 +136,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
|
||||
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
|
||||
|
||||
**3. 单独安装 Skills(Gitee 镜像):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -144,12 +153,18 @@ dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingT
|
||||
```bash
|
||||
dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --list # 列出正式 release 版本
|
||||
dws upgrade --beta # 升级到最新 beta 预发布版本
|
||||
dws upgrade --check --beta # 仅检查 beta 轨道是否有新版本
|
||||
dws upgrade --list --beta # 列出 beta 预发布版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定版本
|
||||
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级
|
||||
```
|
||||
|
||||
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
@@ -163,8 +178,9 @@ dws upgrade -y # 跳过确认直接升级
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--check` | 仅检查更新,不安装 |
|
||||
| `--list` | 列出所有可用版本及更新日志 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7`) |
|
||||
| `--list` | 列出正式 release 版本及更新日志 |
|
||||
| `--beta` | 对 `upgrade`、`--check`、`--list` 使用 beta 预发布轨道 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7` 或 `v1.0.8-beta.1`) |
|
||||
| `--rollback` | 回滚到上一个备份版本 |
|
||||
| `--force` | 强制重新安装,即使已是最新版本 |
|
||||
| `--skip-skills` | 跳过技能包更新 |
|
||||
@@ -225,6 +241,32 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>多组织(profile)</strong></summary>
|
||||
|
||||
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
|
||||
|
||||
```bash
|
||||
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
|
||||
dws profile list # 列出已登录组织(主 / 当前标记、状态)
|
||||
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
|
||||
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
|
||||
```
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
|
||||
|
||||
@@ -276,27 +318,37 @@ dws contact user search --query "张三" --dry-run
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### Schema 发现
|
||||
### 命令帮助与 Schema
|
||||
|
||||
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
|
||||
```bash
|
||||
# 第一步:发现所有可用产品
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
# 确认命令存在并查看当前接受的 flags
|
||||
dws aitable record query --help
|
||||
|
||||
# 第二步:查看目标工具的参数结构
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
|
||||
# 第三步:构造正确的调用
|
||||
# 执行真实业务查询
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
Schema 生成共享的 reviewed 输入单独位于 `internal/cli/schema_hints/`。它们不是 Agent Skill,也不会进入二进制或发布 skill 包。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
@@ -306,6 +358,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
**用 `dws skill setup` 切换或重装:**
|
||||
|
||||
@@ -369,6 +423,51 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
|
||||
## 功能特性
|
||||
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
|
||||
|
||||
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
|
||||
|
||||
只需要 event 能力时,可以使用官方便捷安装脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
|
||||
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
|
||||
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
|
||||
|
||||
@@ -450,7 +549,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -459,13 +558,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
|
||||
```bash
|
||||
dws schema # 列出所有产品和工具
|
||||
dws schema aitable.query_records # 查看参数 Schema
|
||||
dws schema aitable.query_records --jq '.tool.required' # 查看必填字段
|
||||
dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
dws schema aitable # 发现产品命令
|
||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -510,29 +609,29 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 15 | `user` `dept` `label` `relation` | 按姓名 / 手机号 / 工号搜索、批量查询、部门树、角色标签、人员关系、花名册与离职、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 65 | `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` | 17 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 16 | `task` `comment` | 创建、列表、修改、完成、详情、删除,以及任务评论 |
|
||||
| 审批 | `oa` | 15 | `approval` | 同意 / 拒绝 / 撤销 / 转交、待我审批 / 我发起 / 已提交 / 已办 / 抄送、流程表单、评论、操作记录 |
|
||||
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
|
||||
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | 创建 / 提交日志、收发(收件箱 / 发件箱)列表、模版(获取 / 列表)、详情、统计、单条获取 |
|
||||
| AI 表格 | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);数据表表单;模板 |
|
||||
| 文档 | `doc` | 28 | `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` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
|
||||
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
|
||||
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档并排查 API 调用错误 |
|
||||
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
| 服务 | 命令 | 能力 |
|
||||
|------|------|------|
|
||||
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
|
||||
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
|
||||
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
|
||||
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
|
||||
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
|
||||
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
|
||||
| DING | `ding` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
|
||||
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
|
||||
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
|
||||
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
|
||||
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
|
||||
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
|
||||
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
|
||||
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
|
||||
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
|
||||
| 直播 | `live` | 查看我的直播列表 |
|
||||
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
|
||||
|
||||
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看任一服务的子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
@@ -593,7 +692,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
|
||||
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@ warehouse. This page is the integration contract.
|
||||
|
||||
| Header | Meaning | Granularity |
|
||||
|--------|---------|-------------|
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom if explicitly declared …) | channel |
|
||||
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
|
||||
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
|
||||
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
|
||||
@@ -26,7 +26,7 @@ clients send no `agent_code` / instance id — treat their absence as
|
||||
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
|
||||
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
|
||||
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
|
||||
5. **T4 — `custom`:** unknown host. Never guessed.
|
||||
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
|
||||
|
||||
## Declaring your agent (recommended — the only fully-general path)
|
||||
|
||||
@@ -55,8 +55,8 @@ MCP server config example (JSON-style hosts):
|
||||
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
|
||||
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
|
||||
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
|
||||
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
|
||||
spaces stripped), so a new agent name flows through cleanly.
|
||||
Use a stable slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
|
||||
verbatim so PAT grants and follow-up command checks use the same key.
|
||||
|
||||
## Trust & limitations — READ THIS
|
||||
|
||||
|
||||
+36
-17
@@ -1,26 +1,45 @@
|
||||
# Architecture
|
||||
|
||||
`dws` is a Go CLI that turns DingTalk MCP metadata into a command-line surface for both humans and AI agents.
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; the embedded Command Catalog serves AI agents.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
1. `internal/market` fetches the registry and server metadata.
|
||||
2. `internal/discovery` resolves runtime server capabilities and caches results.
|
||||
3. `internal/ir` normalizes discovery output into one canonical tool catalog.
|
||||
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree.
|
||||
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses.
|
||||
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
|
||||
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`), product helpers, and versioned plugin descriptors.
|
||||
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
|
||||
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
|
||||
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
|
||||
6. Schema generation starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, Agent hints, and Skills into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`.
|
||||
7. The embedded Catalog is a downstream release artifact and never backfills identity or participates in regeneration. Stable flag-to-interface property bindings come from the reviewed, content-addressed v3 manifest in `schema_parameter_bindings.json`; its exact active tuples, corrections, removals, and mapping exclusions are validated against the final bound `SchemaRegistry`. CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
8. Agent selection results are fixed in versioned review inputs. Every public tool has explicit use/avoid/example and interface disposition metadata; Skill references that are not current leaves require an explicit alias/group/stale/out-of-surface review instead of fuzzy runtime matching.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `cmd`: CLI entrypoint
|
||||
- `internal/app`: root command wiring and static utility commands
|
||||
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution
|
||||
- `internal/ir`: canonical intermediate representation for discovered tools
|
||||
- `internal/generator`: docs, schema, and skill generation pipeline
|
||||
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands
|
||||
- `skills/`: bundled agent skills source and generated skill docs
|
||||
- `test/`: CLI, compatibility, integration, contract, and script tests
|
||||
|
||||
## Public Repository Contract
|
||||
|
||||
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow.
|
||||
- `internal/app`: root command wiring, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: versioned plugin manifest, hook, skill, and transport descriptor loading
|
||||
- `internal/cli`: embedded Agent Command Catalog, static schema query, and catalog contracts
|
||||
- `internal/generator`: deterministic Agent metadata and Command Catalog generators
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection, identity
|
||||
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
|
||||
- `internal/errors`: structured error model with categories and hints
|
||||
- `internal/keychain`: OS keychain integration for credential storage
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
|
||||
- `internal/cobracmd`: shared Cobra command builders
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
- `internal/tui`: terminal UI helpers
|
||||
- `internal/recovery`: panic recovery and graceful degradation
|
||||
- `pkg/configmeta`: environment variable registry and documentation
|
||||
- `pkg/config`: configuration constants and paths
|
||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
||||
- `pkg/mcptypes`: MCP protocol type definitions
|
||||
- `internal/syncdata`: generated static endpoint and command-routing data synced from the Wukong baseline
|
||||
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E tests
|
||||
- `scripts/`: install scripts, policy checks, and CI helpers
|
||||
|
||||
+26
-25
@@ -13,40 +13,44 @@ repository root while preserving repo-local guidance for automation.
|
||||
## Project Snapshot
|
||||
|
||||
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
|
||||
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots.
|
||||
- Compatibility and helper surfaces are overlays, not the canonical truth.
|
||||
- Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
|
||||
- Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `cmd`: public CLI entrypoint
|
||||
- `internal/app`: root command wiring and command tree mount points
|
||||
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport
|
||||
- `internal/generator`: CLI/schema/docs/skills generation pipeline
|
||||
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands
|
||||
- `internal/app`: root command wiring, static utility commands, plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: plugin-based dynamic command loader
|
||||
- `internal/cli`: catalog types and static endpoint loader
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection
|
||||
- `internal/audit`: user operation audit log
|
||||
- `internal/errors`: structured error model with categories and hints
|
||||
- `internal/keychain`: OS keychain integration for credential storage
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `docs/`: public architecture and reference docs
|
||||
- `hack/`: developer-only helper commands not shipped as public binaries
|
||||
- `scripts/`: build, test, lint, packaging, and policy checks
|
||||
- `test/`: integration, contract, compatibility, and script validation suites
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E test suites
|
||||
|
||||
## Task Routing
|
||||
|
||||
- Add or fix a command path: start from `internal/app` and the related module under `internal/*`
|
||||
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport`
|
||||
- Generated output drift: inspect `internal/generator` and run drift checks
|
||||
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat`
|
||||
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors`
|
||||
- Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
|
||||
- Protocol or transport issues: inspect `internal/transport`
|
||||
- Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
|
||||
- Error message or category issues: inspect `internal/errors`
|
||||
- Audit log issues: inspect `internal/audit`
|
||||
- Plugin loading or command surface: inspect `internal/plugin`
|
||||
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
|
||||
|
||||
## Generated Artifacts
|
||||
## Policy Checks
|
||||
|
||||
Prefer editing source logic instead of generated files directly.
|
||||
When command surface or plugin descriptors change, run:
|
||||
|
||||
- Generated-heavy paths:
|
||||
- `docs/generated/`
|
||||
- `skills/generated/`
|
||||
- `test/golden/generated_outputs/`
|
||||
- When generator or command surface changes, run:
|
||||
- `./scripts/policy/check-generated-drift.sh`
|
||||
- `./scripts/policy/check-command-surface.sh --strict`
|
||||
- `./scripts/policy/check-command-surface.sh --strict`
|
||||
- `./scripts/policy/check-open-source-assets.sh`
|
||||
|
||||
## Common Commands
|
||||
|
||||
@@ -55,9 +59,6 @@ make build
|
||||
make test
|
||||
make lint
|
||||
./scripts/dev/ci-local.sh
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-command-surface.sh --strict
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Edit `pre/config.json`, not this file.
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
|
||||
## Global flags
|
||||
|
||||
@@ -148,8 +147,8 @@ _Group chats, conversations, messages, and robot/webhook integrations._
|
||||
| `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` | Pull the recent message history of a specific conversation, including quoted-message context for merged forwards and images. | When the agent needs to read what has recently been said in a conversation and retain the context of replies. |
|
||||
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range, surfacing any search-entitlement guidance. | 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. |
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
# Running the connector as a 7x24 service
|
||||
|
||||
`dws devapp robot connect` keeps a DingTalk robot wired to a local agent over a
|
||||
`dws dev connect` keeps a DingTalk robot wired to a local agent over a
|
||||
Stream long-connection. By default it runs in the foreground and dies when the
|
||||
terminal closes. For an unattended "digital employee" you have two options.
|
||||
|
||||
> **Security**: prefer `--unified-app-id <uappid>` over
|
||||
> `--robot-client-id/--robot-client-secret`. With `--unified-app-id` the CLI
|
||||
> resolves clientId/clientSecret at runtime through `dev app credentials get`,
|
||||
> so the secret never appears in `ps` / journald / shell history. Pasting
|
||||
> `--robot-client-secret` onto argv lets any local user read your AppSecret
|
||||
> with `ps -ef`; the CLI will warn you when you do that.
|
||||
|
||||
## Option A: built-in daemon (recommended for a quick start)
|
||||
|
||||
```bash
|
||||
# Detach into a background supervisor that restarts the connector if it crashes.
|
||||
dws devapp robot connect --daemon \
|
||||
dws dev connect --daemon \
|
||||
--channel claudecode \
|
||||
--robot-client-id <clientId> --robot-client-secret <clientSecret>
|
||||
--unified-app-id <unifiedAppId>
|
||||
|
||||
# Inspect / stop it.
|
||||
dws devapp robot connect status --robot-client-id <clientId>
|
||||
dws devapp robot connect stop --robot-client-id <clientId>
|
||||
# Inspect / stop / restart it (locate the daemon by unifiedAppId).
|
||||
dws dev connect status --unified-app-id <unifiedAppId>
|
||||
dws dev connect stop --unified-app-id <unifiedAppId>
|
||||
dws dev connect restart --unified-app-id <unifiedAppId>
|
||||
```
|
||||
|
||||
- The parent prints the daemon pid and the log path, then exits.
|
||||
@@ -23,9 +31,9 @@ dws devapp robot connect stop --robot-client-id <clientId>
|
||||
10 consecutive fast failures) when it exits abnormally.
|
||||
- The single-instance lock (one connector per robot per machine) is reused, so a
|
||||
duplicate daemon refuses to start.
|
||||
- Logs go to `~/.dws/connect/<clientId>/daemon.log` with size-based rotation
|
||||
- Logs go to `~/.dws/connect/<key>/daemon.log` with size-based rotation
|
||||
(5 MB x 2 backups), and the pid file lives at
|
||||
`~/.dws/connect/<clientId>/daemon.pid`.
|
||||
`~/.dws/connect/<key>/daemon.pid`.
|
||||
- The daemon does NOT survive a reboot. For that, use Option B.
|
||||
|
||||
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
|
||||
@@ -39,7 +47,7 @@ restart it. This is the most robust way to get boot-time auto-start.
|
||||
### macOS — launchd
|
||||
|
||||
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
|
||||
and credentials, then `launchctl load -w <path>`.
|
||||
and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -52,15 +60,12 @@ and credentials, then `launchctl load -w <path>`.
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/dws</string>
|
||||
<string>devapp</string>
|
||||
<string>robot</string>
|
||||
<string>dev</string>
|
||||
<string>connect</string>
|
||||
<string>--channel</string>
|
||||
<string>claudecode</string>
|
||||
<string>--robot-client-id</string>
|
||||
<string>REPLACE_CLIENT_ID</string>
|
||||
<string>--robot-client-secret</string>
|
||||
<string>REPLACE_CLIENT_SECRET</string>
|
||||
<string>--unified-app-id</string>
|
||||
<string>REPLACE_UNIFIED_APP_ID</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -86,8 +91,8 @@ itself relies on the single-instance lock to avoid duplicates.
|
||||
|
||||
### Linux — systemd (user service)
|
||||
|
||||
Save as `~/.config/systemd/user/dws-connect.service`, edit paths/credentials,
|
||||
then:
|
||||
Save as `~/.config/systemd/user/dws-connect.service`, edit paths and
|
||||
`REPLACE_UNIFIED_APP_ID`, then:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
@@ -104,10 +109,9 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/dws devapp robot connect \
|
||||
ExecStart=/usr/local/bin/dws dev connect \
|
||||
--channel claudecode \
|
||||
--robot-client-id REPLACE_CLIENT_ID \
|
||||
--robot-client-secret REPLACE_CLIENT_SECRET
|
||||
--unified-app-id REPLACE_UNIFIED_APP_ID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Optional hardening:
|
||||
@@ -121,6 +125,22 @@ WantedBy=default.target
|
||||
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
|
||||
stderr into the journal (`journalctl --user -u dws-connect -f`).
|
||||
|
||||
## Legacy: passing clientId/clientSecret directly (not recommended)
|
||||
|
||||
If you truly must pass credentials on the command line (e.g. one-off local
|
||||
debugging without a unifiedAppId), the CLI still accepts
|
||||
`--robot-client-id <id> --robot-client-secret <secret>` and will print a
|
||||
security warning to stderr. This form:
|
||||
|
||||
- exposes `clientSecret` to every user on the box via `ps -ef`;
|
||||
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
|
||||
makes rotation harder;
|
||||
- means `dws dev connect restart` cannot re-fetch credentials — you
|
||||
must re-run the full command yourself.
|
||||
|
||||
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
|
||||
trade-off.
|
||||
|
||||
## Which to choose
|
||||
|
||||
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
|
||||
|
||||
@@ -108,9 +108,9 @@
|
||||
- **期望(分步)**:
|
||||
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
|
||||
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
|
||||
3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。
|
||||
3. 建联:`dev connect --channel auto --unified-app-id UAID --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。
|
||||
|
||||
### C9. 事件订阅与上游错误排查
|
||||
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# DevApp 一键安装与 Agent 接入指南
|
||||
# dws dev 一键安装与 Agent 接入指南
|
||||
|
||||
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
|
||||
|
||||
@@ -6,45 +6,44 @@
|
||||
|
||||
## 一键安装
|
||||
|
||||
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
### Windows(PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
|
||||
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
|
||||
2. 使用本地源码构建 `dws`。
|
||||
3. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
|
||||
|
||||
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows PowerShell 正式安装命令:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
安装脚本支持这些环境变量:
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
|
||||
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
|
||||
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
|
||||
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
|
||||
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
|
||||
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
|
||||
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
|
||||
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
### 国内加速
|
||||
|
||||
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
## 安装后验证
|
||||
|
||||
@@ -54,13 +53,13 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
dws version
|
||||
```
|
||||
|
||||
确认 DevApp 命令存在:
|
||||
确认 `dws dev app` 命令存在:
|
||||
|
||||
```bash
|
||||
dws devapp --help --format json
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
|
||||
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
|
||||
|
||||
确认登录状态:
|
||||
|
||||
@@ -77,14 +76,18 @@ dws auth login
|
||||
登录完成后读取应用列表:
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws dev app list --format json
|
||||
```
|
||||
|
||||
## DevApp 是什么
|
||||
## dws dev 是什么
|
||||
|
||||
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
|
||||
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
|
||||
|
||||
它让 Agent 可以完成这些工作:
|
||||
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
|
||||
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
|
||||
- `dws dev doc` — 开放平台开发文档搜索。
|
||||
|
||||
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
|
||||
|
||||
- 查询、创建、更新、启用、停用、删除开放平台应用。
|
||||
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
|
||||
@@ -92,12 +95,12 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
|
||||
- 查询、申请、移除权限点。
|
||||
- 管理应用成员。
|
||||
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
|
||||
- 创建、查询、更新、启用、停用机器人。
|
||||
- 异步创建机器人、配置/启停现有机器人。
|
||||
- 创建版本、发起发布、查询审批和发布状态。
|
||||
|
||||
## 给 Agent 使用
|
||||
|
||||
安装完成后,可以直接让 Agent 操作 DevApp。
|
||||
安装完成后,可以直接让 Agent 操作 `dws dev`。
|
||||
|
||||
示例:
|
||||
|
||||
@@ -114,7 +117,7 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
|
||||
```
|
||||
|
||||
```text
|
||||
帮我发布这个应用版本,先检查发布前置条件。
|
||||
帮我发布这个应用版本,先预检是否需要审批。
|
||||
```
|
||||
|
||||
Agent 写操作必须遵循:
|
||||
@@ -127,34 +130,30 @@ Agent 写操作必须遵循:
|
||||
|
||||
## 第一个写操作
|
||||
|
||||
推荐用机器人配置作为 smoke test。先 dry-run:
|
||||
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
|
||||
|
||||
提交建号任务(记下返回的 `taskId`):
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
dws dev app robot submit \
|
||||
--name "告警助手" \
|
||||
--robot-name "告警机器人" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
确认预览无误后执行:
|
||||
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--yes \
|
||||
--format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
```
|
||||
|
||||
回读验证:
|
||||
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
@@ -162,19 +161,21 @@ dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws devapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp create --name "考勤应用" --dry-run --format json
|
||||
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app list --format json
|
||||
dws dev app get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app create --name "考勤应用" --dry-run --format json
|
||||
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
|
||||
```
|
||||
|
||||
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
|
||||
|
||||
### 凭证查询
|
||||
|
||||
```bash
|
||||
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
|
||||
@@ -182,68 +183,82 @@ dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
### 权限点管理
|
||||
|
||||
```bash
|
||||
dws devapp permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
```
|
||||
|
||||
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
|
||||
|
||||
### 机器人配置
|
||||
### 机器人能力
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 成员与安全
|
||||
|
||||
```bash
|
||||
dws devapp member list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
dws dev app member list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
```
|
||||
|
||||
### 网页应用与事件
|
||||
|
||||
```bash
|
||||
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
|
||||
dws dev app event list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 版本发布
|
||||
|
||||
```bash
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
|
||||
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
```
|
||||
|
||||
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
|
||||
默认安全策略:
|
||||
|
||||
- 写操作先 dry-run。
|
||||
- 删除、停用、发布必须由用户确认。
|
||||
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
|
||||
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
|
||||
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
|
||||
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
|
||||
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
|
||||
|
||||
## 排障
|
||||
|
||||
### `dws devapp` 不存在
|
||||
### `dws dev app` 不存在
|
||||
|
||||
先确认安装的是预览分支源码,而不是正式 Release:
|
||||
先确认装上的是带 `dws dev` 的版本:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
dws devapp --help --format json
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
|
||||
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
|
||||
|
||||
### `dws devapp list` 失败
|
||||
### `dws dev app list` 失败
|
||||
|
||||
优先检查登录态:
|
||||
|
||||
@@ -254,11 +269,13 @@ dws auth login
|
||||
|
||||
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
|
||||
|
||||
### 提示"当前用户没有开发者身份"
|
||||
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
|
||||
|
||||
### 页面能操作,但 CLI 或 MCP 提示无权限
|
||||
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
|
||||
|
||||
先确认当前用户是否满足以下任一条件:
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
|
||||
|
||||
- 应用 owner。
|
||||
- 应用管理员。
|
||||
@@ -270,18 +287,17 @@ dws auth login
|
||||
先查当前机器人状态:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
如果机器人不存在,使用 `robot config` 创建或配置。
|
||||
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
|
||||
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
|
||||
|
||||
## 页面文案建议
|
||||
|
||||
用于产品页顶部:
|
||||
|
||||
```text
|
||||
Install DevApp in one command.
|
||||
Install dws dev in one command.
|
||||
|
||||
Let your coding agents manage DingTalk Open Platform apps from the terminal:
|
||||
create apps, configure robots, apply permissions, manage security settings,
|
||||
@@ -291,7 +307,7 @@ and publish versions with dry-run safety built in.
|
||||
中文版本:
|
||||
|
||||
```text
|
||||
一行命令接入 DevApp。
|
||||
一行命令接入 dws dev。
|
||||
|
||||
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
|
||||
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
# Event consume — AI subprocess contract
|
||||
|
||||
Aligns `dws event consume` with the "AI subprocess contract" that
|
||||
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
|
||||
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
|
||||
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
|
||||
exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
## Baseline (already present, no work)
|
||||
|
||||
- `--max-events N` — stop after N events (exit 0).
|
||||
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
|
||||
aliased to `--timeout`: the global `--timeout` is the HTTP request
|
||||
timeout (int seconds) and would collide (different type and meaning).
|
||||
Docs note the lark-cli name difference.
|
||||
- Bus idle-shutdown fires only with **zero** consumers, so a connected
|
||||
consumer is never idle-killed.
|
||||
- SIGINT/SIGTERM already cancel the run context and return cleanly.
|
||||
|
||||
## Improvements
|
||||
|
||||
### 1. Ready marker (standardized)
|
||||
|
||||
On connect, emit a fixed stderr line **before** any stdout event:
|
||||
|
||||
```
|
||||
[event] ready event_key=<key> bus_pid=<pid>
|
||||
```
|
||||
|
||||
Parents block on stderr until this line, then read stdout. Suppressed
|
||||
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
|
||||
omits `event_key`).
|
||||
|
||||
**Verification**
|
||||
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
|
||||
- T1b: that line appears before the first stdout event (ordering).
|
||||
- T1c: with `--quiet`, the line is absent.
|
||||
|
||||
### 2. stdin EOF = graceful exit
|
||||
|
||||
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
|
||||
subprocess callers). To stay resident, feed a never-EOF stdin
|
||||
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
|
||||
|
||||
**Verification**
|
||||
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
|
||||
line `reason: signal` (stdin-eof classified as signal).
|
||||
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
|
||||
5s, connection intact.
|
||||
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
|
||||
via the cleanup path.
|
||||
|
||||
### 3. Exit reason contract + exit codes
|
||||
|
||||
On exit, final stderr line:
|
||||
|
||||
```
|
||||
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
|
||||
```
|
||||
|
||||
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
|
||||
or runtime failure (permissions, network, params) = non-zero, with no
|
||||
`exited` line and an `Error:` line instead.
|
||||
|
||||
**Verification**
|
||||
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
|
||||
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
|
||||
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
|
||||
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
|
||||
- Unit tests assert (reason string, exit code) for each path.
|
||||
|
||||
### 4. Cleanup on exit (no `kill -9`)
|
||||
|
||||
Ownership-based, matching lark-cli:
|
||||
- If this run **created** the subscription (no `--subscribe-id`), a clean
|
||||
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
|
||||
it server-side and sends Bye.
|
||||
- If `--subscribe-id` was passed (reusing an existing subscription), the
|
||||
subscription is **left intact** — the caller owns its lifecycle.
|
||||
- `--ephemeral` remains as an explicit "always unsubscribe" override.
|
||||
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
|
||||
server-side subscription: "subscription already exists" on restart,
|
||||
duplicate delivery). Prefer SIGTERM or closing stdin.
|
||||
|
||||
**Verification**
|
||||
- T4a: start consume (self-created subscription), record subscribe_id;
|
||||
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
|
||||
and the server-side subscription is gone.
|
||||
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
|
||||
subscription is still present (reuse case preserved).
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
|
||||
down and takes consume with it (the likely cause of the observed silent
|
||||
drop). Making more drops retryable, keeping the bus alive across a
|
||||
reconnect, and emitting `reason: source_lost` only after exhausting the
|
||||
budget — tracked on its own branch, since it needs error-classification
|
||||
judgement and real flaky-network testing, and would otherwise couple clean
|
||||
contract work with resilience work.
|
||||
|
||||
## Test surface
|
||||
|
||||
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
|
||||
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
|
||||
- Integration/e2e: `--foreground` + mock source (or a short real run) for
|
||||
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
|
||||
codes.
|
||||
+40
-25
@@ -5,7 +5,7 @@
|
||||
| Variable | Purpose / 用途 |
|
||||
|---------|---------|
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `DWS_SERVERS_URL` | Point discovery at a custom server registry endpoint / 将服务发现指向自定义端点 |
|
||||
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||
| `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`,仅开发环境可设为 `*` |
|
||||
@@ -22,7 +22,7 @@
|
||||
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
|
||||
| 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 / 服务发现、缓存或协议协商失败 |
|
||||
| 6 | Discovery | Static endpoint resolution or protocol negotiation failure / 静态端点解析或协议协商失败 |
|
||||
|
||||
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
|
||||
|
||||
@@ -34,7 +34,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
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 设计)
|
||||
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -51,44 +51,59 @@ dws contact user search --query "Alice" -o result.json
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本内嵌的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 来自 reviewed `CommandRegistry`,并在发布时逐项绑定当前 Cobra tree。编辑 `internal/cli/schema_command_registry.json` 时必须遵守同目录的 `schema_command_registry.schema.json`;普通生成流程只校验该 reviewed input,不会覆盖它。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
|
||||
### 路径写法
|
||||
|
||||
```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 着色分区展示(人肉查看最舒服)
|
||||
dws schema # 当前公开产品面的紧凑概览
|
||||
dws schema calendar # 展开一个产品
|
||||
dws schema "calendar event" # 展开一个命令分组
|
||||
dws schema "calendar event create" # 按 CLI 空格路径查询工具
|
||||
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
|
||||
dws schema --cli-path "calendar event create" # 显式 CLI path
|
||||
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
|
||||
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
||||
```
|
||||
|
||||
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
|
||||
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
|
||||
|
||||
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
|
||||
|
||||
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
|
||||
|
||||
### Schema、Help 与业务数据的边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|------|--------|
|
||||
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
|
||||
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
||||
|
||||
### 单工具输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `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` 确认 |
|
||||
| `auth` | DingTalk 授权元数据,包括 `requiredScopes` / `requiredPermissions` / `recommendedScopes` / `grantProductCodes` / `riskAction` / `confirmationRequired` |
|
||||
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
|
||||
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
|
||||
| `canonical_path` / `primary_cli_path` / `aliases` | 稳定工具 ID、主 CLI 路径和兼容路径 |
|
||||
| `product_id` / `interface_ref` | CLI 产品与实际 MCP product/RPC binding |
|
||||
| `title` / `description` / `agent_summary` | 人类说明、接口说明和 Agent 摘要 |
|
||||
| `parameters.<flag>` | CLI flag 的类型、属性名、required、默认值、格式、枚举和条件必填 |
|
||||
| `constraints` | one-of、互斥、联动等组合约束 |
|
||||
| `effect` / `risk` / `confirmation` / `idempotency` | Agent 执行与安全策略 |
|
||||
| `use_when` / `avoid_when` / `examples` | Agent 选择提示和示例 |
|
||||
| `reviewed` / `agent_source_refs` | 语义审核状态与来源追踪 |
|
||||
|
||||
**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
|
||||
`parameters.<flag>.required` 是按来源 precedence 解析后的 Agent 参数契约;`cli_required=true` 才表示 Cobra 将该 flag 标记为硬必填。条件必填或别名选择通过 `required_when` 和 `constraints.require_one_of` 表达。`required` 不直接复制 MCP input schema,也不取代 Cobra 的实际执行校验。
|
||||
|
||||
### 筛选输出
|
||||
|
||||
```bash
|
||||
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
|
||||
dws schema calendar.create_event --jq '.tool.auth' # 只看授权元数据
|
||||
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
|
||||
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
|
||||
dws schema "calendar event create" --jq '.parameters' # 只看参数
|
||||
dws schema "calendar event create" --jq '[.parameters | to_entries[] | select(.value.required)]' # 只看 Agent required 参数
|
||||
```
|
||||
|
||||
## Shell Completion / 自动补全
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
打开终端,整段复制执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
> ```bash
|
||||
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
> ```
|
||||
|
||||
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
|
||||
|
||||
### Windows
|
||||
@@ -23,12 +28,12 @@ curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/fe
|
||||
打开 PowerShell,整段复制执行:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
|
||||
|
||||
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
|
||||
### 登录钉钉
|
||||
|
||||
@@ -50,18 +55,21 @@ dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "
|
||||
dws dev app robot result --task-id 上一步返回的taskId --format json
|
||||
```
|
||||
|
||||
`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。
|
||||
`status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。)
|
||||
|
||||
## 第三步:把机器人接上你本地的 AI
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy
|
||||
dws dev connect --channel auto --unified-app-id 上一步的unifiedAppId
|
||||
```
|
||||
|
||||
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值
|
||||
- 把 `上一步的unifiedAppId` 换成第二步返回的 `unifiedAppId` 实际值
|
||||
- 只用 `--unified-app-id`:`clientSecret` 由 `dws dev app credentials get` 后台取回,**不会出现在你的命令行**,不会被 `ps` 看到、不会留在 shell 历史里
|
||||
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
|
||||
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
|
||||
|
||||
> 安全提示:老写法 `--robot-client-id <id> --robot-client-secret <secret>` 仍然能用,但 `clientSecret` 会以明文出现在命令行,任何本机用户 `ps -ef` 都能拉到;dws 会在 stderr 打一条 WARNING 提醒。除了没有 unifiedAppId 的老应用兜底之外,都建议改用 `--unified-app-id`。
|
||||
|
||||
## 第四步:拉进群聊
|
||||
|
||||
在钉钉里打开目标群:
|
||||
@@ -90,7 +98,7 @@ dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-cl
|
||||
```bash
|
||||
dws dev connect \
|
||||
--agent-cmd "你的AI命令 一次性问答参数" \
|
||||
--robot-client-id dingxxxx --robot-client-secret yyyy
|
||||
--unified-app-id 你的unifiedAppId
|
||||
```
|
||||
|
||||
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# DWS Agent Schema 统一方案
|
||||
|
||||
## 1. 核心定义
|
||||
|
||||
DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描述真实 Cobra 命令,并补充 Agent 选择、参数映射、组合约束、安全确认和接口事实。
|
||||
|
||||
设计遵循三条硬规则:
|
||||
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
|
||||
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
|
||||
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
|
||||
|
||||
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
|
||||
|
||||
## 2. 单向数据流
|
||||
|
||||
```text
|
||||
schema_command_registry.json (reviewed CommandRegistry source)
|
||||
+ reviewed manual command additions
|
||||
|
|
||||
v
|
||||
EffectiveCommandRegistry
|
||||
|
|
||||
v
|
||||
exact binder to live Cobra tree
|
||||
+ native identity consistency assertions
|
||||
|
|
||||
v
|
||||
BoundCommandRegistry
|
||||
|
|
||||
+----------------------+
|
||||
|
|
||||
skills/mono Markdown + internal/cli/schema_hints/*.json |
|
||||
+ schema_mcp_metadata.json |
|
||||
| |
|
||||
v |
|
||||
Agent-metadata normalization |
|
||||
| |
|
||||
v |
|
||||
schema_agent_metadata/*.json |
|
||||
(generated normalized input) |
|
||||
| |
|
||||
+-----------------------+
|
||||
|
|
||||
live Cobra flag facts / typed parameter metadata
|
||||
+ schema_hints/metadata/*.json (reviewed parameter overlay + safety)
|
||||
+ schema_hints/selection/*.json (reviewed Agent selection prose)
|
||||
+ schema_parameter_bindings.json (reviewed flag -> RPC property)
|
||||
+ schema_mcp_metadata.json (pinned, sanitized interface facts)
|
||||
+ normalized Agent metadata
|
||||
|
|
||||
v
|
||||
source adapters + resolvers
|
||||
|
|
||||
v
|
||||
one typed SchemaRegistry
|
||||
(one ToolSpec per command)
|
||||
+
|
||||
typed SchemaIndex
|
||||
+-----------+-----------+
|
||||
| |
|
||||
v v
|
||||
build-time typed gates snapshot serializer
|
||||
|
|
||||
v
|
||||
schema_catalog.json
|
||||
(release output only)
|
||||
|
|
||||
v
|
||||
go:embed -> typed loader
|
||||
|
|
||||
v
|
||||
SchemaRegistry + SchemaIndex
|
||||
|
|
||||
+---------------------+------------------+
|
||||
| | |
|
||||
overview/product/group leaf --all
|
||||
projections projection full projection
|
||||
| | |
|
||||
+---------------------+------------------+
|
||||
|
|
||||
v
|
||||
runtime query + delivery gates
|
||||
```
|
||||
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、manual hint 或生成 JSON 重新解析 command identity。
|
||||
|
||||
## 3. 与 Lark 的关系
|
||||
|
||||
DWS 与 Lark 保持**架构同构**,而不是强行复制字段:
|
||||
|
||||
| Lark 分层 | DWS 对应层 |
|
||||
|---|---|
|
||||
| typed command/metadata registry | `EffectiveCommandRegistry`、`BoundCommandRegistry` 与最终 `SchemaRegistry` |
|
||||
| navigation catalog/index | 从同一 `ToolSpec` 派生的 `SchemaIndex` |
|
||||
| schema renderer/envelope | overview、product/group、leaf、`--all` projections |
|
||||
|
||||
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 base Registry 与 reviewed manual command additions 在绑定前合并为唯一的 `EffectiveCommandRegistry`,因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。
|
||||
|
||||
DWS 内部 resolved model 为:
|
||||
|
||||
```text
|
||||
SchemaRegistry
|
||||
-> []ProductSpec
|
||||
-> []ToolSpec
|
||||
-> ToolIdentitySpec
|
||||
-> []ParameterSpec
|
||||
-> RuntimeSchemaConstraints + []RuntimeSchemaPositional
|
||||
-> SafetySpec
|
||||
-> InterfaceSpec
|
||||
-> SelectionSpec
|
||||
-> map[field]FieldProvenance
|
||||
```
|
||||
|
||||
字段合并和 precedence 在进入该模型前完成。`map[string]any`/flat JSON 只允许存在于 renderer 和 snapshot/wire boundary,不能作为内部 resolver、navigation 或 gate 的第二套数据模型。
|
||||
|
||||
DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和选择字段也保持现有键名。架构对齐不等于未版本化地切换到 Lark `inputSchema/outputSchema/_meta` envelope;若未来提供该格式,应作为明确版本的新投影,并保留现有兼容输出。
|
||||
|
||||
## 4. 来源职责
|
||||
|
||||
| 来源 | 负责内容 | 明确不负责 |
|
||||
|---|---|---|
|
||||
| `schema_command_registry.json` | reviewed `CommandRegistry`:稳定 canonical identity、primary CLI path、alias、exposure 和导航 | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
|
||||
| reviewed manual command additions | 将一个精确存在的 runnable Cobra leaf 合并进 `EffectiveCommandRegistry`;必须 reviewed 且带 reason | 运行时 fallback、覆盖冲突 identity、创建命令 |
|
||||
| Go/Cobra | 路径是否真实可执行、Cobra 接受的 flag、CLI 类型/默认值、执行校验、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC |
|
||||
| native Schema identity annotations | implementation-side consistency evidence;存在时必须与 `EffectiveCommandRegistry` 精确一致 | 提供、补全、推断或覆盖 identity |
|
||||
| `schema_hints/metadata/*.json` parameter overlays | 精确覆盖现有 flag 的描述、映射、类型和 required 语义;并承载 safety / `runtime_gate` / interface | 创建命令/flag、绕过 completeness、虚构 RPC |
|
||||
| typed parameter metadata / constraints | `required_when`、one-of、互斥、联动、格式、枚举、位置参数 | 命令 identity |
|
||||
| `schema_parameter_bindings.json` | 稳定 CLI flag 到 RPC property 的映射 | 命令发现、risk 推断 |
|
||||
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
|
||||
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
|
||||
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
|
||||
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
|
||||
|
||||
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
|
||||
|
||||
## 5. 统一解析与 precedence
|
||||
|
||||
### 5.1 Identity
|
||||
|
||||
- Reviewed base `CommandRegistry` 是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源。
|
||||
- Reviewed manual command addition 只能引用精确存在的 runnable Cobra leaf;它在绑定前合并进 `EffectiveCommandRegistry`。若与 base Registry 的 identity/path/alias 冲突,生成失败,不能按 precedence 静默覆盖。
|
||||
- Binder 必须把 effective entry 的 primary path 和每个 alias 精确解析到同一个真实 executable leaf;stale path、phantom path、重复 identity 或 alias collision 全部失败。
|
||||
- Native identity annotation 是可选的一致性证据:存在时必须与 effective entry 精确一致;缺失不触发补写、推断或 fallback。
|
||||
- Public runnable Cobra leaf 未进入 effective registry 时,必须存在 exact、reviewed、带 reason 的 exclusion;不得用 prefix/wildcard 排除。
|
||||
- Identity 不做名称推断,不从 Catalog/generated metadata fallback,也没有多来源 winner。
|
||||
|
||||
删除 native materialization 前已做写入审计:旧
|
||||
`ApplyNativeRuntimeSchemaContracts` 的唯一写操作是对已存在命令调用
|
||||
`AttachRuntimeSchema`,只写 command identity 的 product/tool/source annotation;
|
||||
它不写 flag property/type/required、constraints、positionals、title/description
|
||||
或 interface mapping。这些字段原本已分别由 parameter binding/metadata、
|
||||
constraint、Cobra help 和 interface resolver 提供,因此删除该过渡层没有数据迁移缺口。
|
||||
CI 同时禁止重新加入 generated native contracts 或 materialization 入口。
|
||||
|
||||
#### CommandRegistry 输入审计
|
||||
|
||||
`schema_command_registry.json` 是 reviewed source,不是生成快照。它必须保留
|
||||
`$schema: ./schema_command_registry.schema.json`。该 JSON Schema 对 root、product
|
||||
和 CommandSpec 全部使用 `additionalProperties: false`,并约束:
|
||||
|
||||
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
|
||||
- `aliases` 唯一且不能复用 primary path;
|
||||
- `visibility` 只允许 `public | compat | internal`,省略时明确归一化为
|
||||
`public`;
|
||||
- primary path、alias、canonical 和 product 之间无法由 JSON Schema 表达的
|
||||
交叉约束,继续由 Go strict loader 和 Cobra binder fail-closed 校验。
|
||||
|
||||
Registry semantic hash 覆盖 canonical、primary CLI path、alias 集合、
|
||||
`source_product_id` 和 normalized visibility。格式、顺序以及省略的等价默认值
|
||||
不改变 hash;上述任一稳定契约字段变化都必须改变 hash。测试逐字段验证这一点,
|
||||
不使用当前命令数量作为常量。
|
||||
|
||||
普通 `go generate ./internal/cli` 只把 Registry 作为 validation-only 输入并生成
|
||||
Agent metadata/Catalog 等单向下游资产,不生成或覆盖 Registry。drift policy 在生成
|
||||
前后对 reviewed Registry 做 byte-for-byte guard;独立的
|
||||
`check-schema-command-registry.sh` 在 interface/provenance/Catalog policy 之前检查
|
||||
JSON 输入契约、禁用旧 native materialization 符号,并从 Registry 动态计算审计
|
||||
数量,不能硬编码某次快照的 tool count。
|
||||
|
||||
### 5.2 Parameter
|
||||
|
||||
每个字段按明确的来源 precedence 选择一次,并把 winner、候选值和来源写入 provenance。precedence **与值无关**:不能因为 `required=true` 看起来更严格就让它越级获胜。更高优先级的 reviewed manual override 可以把 `required`、映射、interface type 或描述调高,也可以调低。
|
||||
|
||||
实现中的参数字段顺序固定为:
|
||||
|
||||
```text
|
||||
reviewed manual > versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract > ToolSchemaHint > MCP metadata
|
||||
> inference/default
|
||||
```
|
||||
|
||||
命令 `title` / `description` 使用独立但同样确定的文本顺序:
|
||||
|
||||
```text
|
||||
reviewed ToolSchemaHint > command-specific Cobra Help > MCP metadata > inference
|
||||
```
|
||||
|
||||
因此多个 CLI leaf 复用同一个 RPC 时,通用 RPC 文案只能作为未选中的
|
||||
provenance candidate 保留;参数级 RPC 文案可进入 `interface_description`,
|
||||
但不得覆盖 leaf 自己的标题和执行语义。
|
||||
|
||||
Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/provenance 保留;它不应在 renderer 中再次静默改写已经解析的 Agent projection。
|
||||
|
||||
### 5.3 Safety、selection 与 interface
|
||||
|
||||
`effect`、`risk`、`confirmation`、`idempotency`、selection 和 interface disposition 同样按 source precedence 解析,而不是按值的“严格程度”合并。更高优先级的 reviewed explicit/manual source 可以升高或降低最终值;同 precedence 的不同值必须报冲突。
|
||||
|
||||
最终 interface disposition 还必须满足 conflict matrix:
|
||||
|
||||
- `mode` 与 `availability` 正交:`mode` 只允许 `mcp | local | composite`,`availability` 只允许 `available | unavailable`;`unavailable` 不是第四种 mode。
|
||||
- `mcp + available`:只表示命令可由一个 pinned、参数可映射且语义等价的 `interface_ref` 完整表达;本地 wrapper 只是固定默认值或投影返回值时,也必须先证明参数和执行语义没有漂移。
|
||||
- `local + available`:仅用于纯本地进程、静态数据或策略操作,不得携带 direct `interface_ref`;“远端 RPC 尚未进入 pinned metadata”不能归类为 local。
|
||||
- `composite + available`:用于多 RPC、条件路由、本地投影,或 reviewed unpinned remote adapter;不得用单个 `interface_ref` 冒充完整实现,且必须提供 reviewed reason。未来需要表达多个 RPC 时使用单独的复合接口模型。
|
||||
- 任意合法 mode + `unavailable`:不得携带 `interface_ref`,必须提供明确 reason,并且 Agent 不得把它当作可用接口。
|
||||
|
||||
## 6. Schema、Help 与业务数据边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|---|---|
|
||||
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
|
||||
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
|
||||
- 执行参数以 Cobra 实际接受的 flags 为准;不要发送 Help 中不存在的 flag。
|
||||
- 安全语义冲突时不要采用更宽松值。先按更保守的解释确认;如果无法确定安全执行方式,停止并报告漂移。
|
||||
- Schema/Help 只完成命令发现和契约读取。需要业务结果时,必须继续执行真实 read/search/list 命令。
|
||||
|
||||
上述运行时漂移策略不改变构建期的 value-neutral precedence;前者是在契约已经互相矛盾时保护用户,后者是在确定性生成同一契约。
|
||||
|
||||
## 7. 查询投影
|
||||
|
||||
```bash
|
||||
dws schema # 产品紧凑概览
|
||||
dws schema calendar # 产品摘要
|
||||
dws schema "calendar event" # 分组摘要
|
||||
dws schema "calendar event create" # 完整 leaf
|
||||
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
|
||||
dws schema --all # 所有工具的完整 leaf 导出
|
||||
```
|
||||
|
||||
`schema list` 是根概览的兼容入口。
|
||||
|
||||
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
|
||||
|
||||
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
|
||||
|
||||
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
|
||||
|
||||
## 8. 生成与发布
|
||||
|
||||
当 Cobra、flag、identity、binding、manual hint、Agent hint 或 Skill 发生变化时:
|
||||
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 时,精确编辑 reviewed `CommandRegistry`(当前持久化文件为 `schema_command_registry.json`)。参数、Skill 或 metadata 单独变化时不要机械改写 Registry,也不要从旧 Catalog 反向生成它。
|
||||
2. 仅对明确例外使用 reviewed manual command addition;它必须精确引用现有 runnable leaf、带 reason,并在生成时归一化进 `EffectiveCommandRegistry`。Native identity annotation 若存在,应作为与 Registry 一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成 Agent metadata:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
```
|
||||
|
||||
4. 从统一 typed registry 生成最终 Catalog:
|
||||
|
||||
```bash
|
||||
make generate-schema-catalog
|
||||
```
|
||||
|
||||
也可以运行 `go generate ./internal/cli` 生成正常发布资产。生成文件包括:
|
||||
|
||||
- `internal/cli/schema_agent_metadata/index.json`
|
||||
- `internal/cli/schema_agent_metadata/<product>.json`
|
||||
- `internal/cli/schema_agent_metadata_audit.json`
|
||||
- `internal/cli/schema_catalog.json`
|
||||
|
||||
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
|
||||
|
||||
## 9. Completeness 与 final-delivery invariant
|
||||
|
||||
门禁必须验证最终交付对象,而不是某个中间层或数量:
|
||||
|
||||
- 每个 public runnable Cobra leaf 要么能通过最终 embedded `SchemaIndex` 查询,要么有 exact、reviewed、带 reason 的 exclusion。
|
||||
- 每个最终 canonical path、primary CLI path 和 alias 都必须解析到同一个可执行 leaf;不得有 phantom path 或 collision。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex`、Agent metadata 和 Catalog canonical sets 必须精确一致,不能只比较 count。
|
||||
- Leaf payload、`--all` 中对应 tool 和 Catalog full tool 必须是同一个 resolved `ToolSpec` 的内容级等价投影,并通过 production loader round-trip。
|
||||
- overview/product/group summary 与 Catalog summary 必须等于同一个 `ToolSpec.ToSummaryPayload()`;alias 查询只允许 `cli_path` 和 `is_alias` 这两个视图字段变化。
|
||||
- 每个最终字段及 parameter field 的 provenance winner value 必须与 delivered value 精确一致;不能只验证 provenance source、count 或字段是否存在。
|
||||
- 每个 MCP `interface_ref` 必须在 pinned interface registry 精确存在;local/composite/unavailable 必须满足同一 conflict matrix。
|
||||
- `--all` 的 tool set 必须与最终 index 一对一,且每个工具包含完整参数契约。
|
||||
- 连续两次生成必须字节稳定,提交的生成物不得漂移。
|
||||
|
||||
推荐本地验证:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
make generate-schema-catalog
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-schema-catalog.sh
|
||||
go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
```
|
||||
|
||||
## 10. 明确禁止
|
||||
|
||||
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
|
||||
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
|
||||
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
|
||||
- 用 prefix/wildcard exclusion 隐藏未来命令。
|
||||
- 让 manual hint、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
|
||||
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
|
||||
@@ -1,14 +1,19 @@
|
||||
module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.8
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -21,9 +26,7 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
@@ -34,7 +37,6 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
@@ -44,7 +46,6 @@ require (
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -86,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newAuditCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "操作审计日志管理",
|
||||
Long: "查看、导出和校验本地操作审计日志。",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuditTailCommand(),
|
||||
newAuditExportCommand(),
|
||||
newAuditVerifyCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditTailCommand() *cobra.Command {
|
||||
var n int
|
||||
cmd := &cobra.Command{
|
||||
Use: "tail",
|
||||
Short: "查看最近的审计记录",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if n < 1 {
|
||||
return fmt.Errorf("--lines 必须为正整数,收到 %d", n)
|
||||
}
|
||||
dir := auditDir()
|
||||
file, err := audit.LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无审计记录: %w", err)
|
||||
}
|
||||
lines, err := tailFile(file, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range lines {
|
||||
fmt.Println(line)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditExportCommand() *cobra.Command {
|
||||
var since, until, format string
|
||||
cmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出审计日志",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := auditDir()
|
||||
|
||||
sinceDate := strings.ReplaceAll(since, "-", "")
|
||||
untilDate := strings.ReplaceAll(until, "-", "")
|
||||
|
||||
files, err := audit.AuditFilesInRange(dir, sinceDate, untilDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找审计文件失败: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("指定范围内无审计文件")
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "jsonl":
|
||||
return exportJSONL(files)
|
||||
case "csv":
|
||||
return exportCSV(files)
|
||||
default:
|
||||
return fmt.Errorf("不支持的格式: %s(可选 jsonl, csv)", format)
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditVerifyCommand() *cobra.Command {
|
||||
var file string
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "校验审计日志哈希链完整性",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target := file
|
||||
if target == "" {
|
||||
dir := auditDir()
|
||||
var err error
|
||||
target, err = audit.LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无审计文件: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
valid, brokenAt, err := audit.VerifyFile(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("校验失败: %w", err)
|
||||
}
|
||||
if valid {
|
||||
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
|
||||
} else {
|
||||
fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt)
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func auditDir() string {
|
||||
if dir := os.Getenv(audit.EnvAuditDir); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return filepath.Join(defaultConfigDir(), "audit")
|
||||
}
|
||||
|
||||
func tailFile(path string, n int) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func exportJSONL(files []string) error {
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
fmt.Println(scanner.Text())
|
||||
}
|
||||
f.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportCSV(files []string) error {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
|
||||
header := []string{"timestamp", "execution_id", "user_id", "corp_id", "product", "command", "result", "duration_ms", "error_category"}
|
||||
if err := w.Write(header); err != nil {
|
||||
return fmt.Errorf("写入 CSV 表头失败: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
var evt audit.Event
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("解析审计记录失败 %s:%d: %w", file, lineNum, err)
|
||||
}
|
||||
row := []string{
|
||||
evt.Timestamp.Format(time.RFC3339),
|
||||
evt.ExecutionID,
|
||||
evt.Actor.UserID,
|
||||
evt.Actor.CorpID,
|
||||
evt.Product,
|
||||
evt.Command,
|
||||
evt.Result,
|
||||
strconv.FormatInt(evt.DurationMs, 10),
|
||||
evt.ErrCategory,
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("写入 CSV 记录失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
if err := w.Error(); err != nil {
|
||||
return fmt.Errorf("刷新 CSV 输出失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuditTailRejectsNonPositiveLines(t *testing.T) {
|
||||
for _, n := range []string{"0", "-1"} {
|
||||
cmd := newAuditTailCommand()
|
||||
cmd.SetArgs([]string{"--lines", n})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("--lines %s: expected error, got nil", n)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "正整数") {
|
||||
t.Fatalf("--lines %s: unexpected error: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailFileReturnsLastN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
if err := os.WriteFile(path, []byte("a\nb\nc\nd\ne\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := tailFile(path, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 2 || lines[0] != "d" || lines[1] != "e" {
|
||||
t.Fatalf("got %v, want [d e]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSVWritesHeaderAndRows(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
rec := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1","corp_id":"c1"},"product":"calendar","command":"event_list","result":"success","duration_ms":12,"hash":"h","prev_hash":""}`
|
||||
if err := os.WriteFile(path, []byte(rec+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
exportErr := exportCSV([]string{path})
|
||||
w.Close()
|
||||
os.Stdout = stdout
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatalf("exportCSV error: %v", exportErr)
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Read(buf)
|
||||
out := string(buf[:n])
|
||||
if !strings.Contains(out, "timestamp,execution_id") {
|
||||
t.Fatalf("missing CSV header, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "e1") || !strings.Contains(out, "event_list") {
|
||||
t.Fatalf("missing CSV row data, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportCSVFailsOnMalformedJSON guards the reviewer's V9 finding: a corrupt
|
||||
// JSONL line must surface an error with file/line evidence instead of being
|
||||
// silently skipped while the command exits 0.
|
||||
func TestExportCSVFailsOnMalformedJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
good := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1"},"product":"calendar","command":"event_list","result":"success","duration_ms":1,"hash":"h","prev_hash":""}`
|
||||
if err := os.WriteFile(path, []byte(good+"\nnot-json\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
exportErr := exportCSV([]string{path})
|
||||
w.Close()
|
||||
os.Stdout = stdout
|
||||
// Drain the pipe so the writer never blocks.
|
||||
buf := make([]byte, 4096)
|
||||
_, _ = r.Read(buf)
|
||||
|
||||
if exportErr == nil {
|
||||
t.Fatal("expected error on malformed JSONL, got nil")
|
||||
}
|
||||
if !strings.Contains(exportErr.Error(), "解析审计记录失败") {
|
||||
t.Fatalf("error missing parse context: %v", exportErr)
|
||||
}
|
||||
if !strings.Contains(exportErr.Error(), ":2") {
|
||||
t.Fatalf("error missing line evidence: %v", exportErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"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/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
auditSinkOnce sync.Once
|
||||
auditCloseOnce sync.Once
|
||||
sharedAuditSink audit.Sink
|
||||
|
||||
auditIDMu sync.Mutex
|
||||
cachedActor audit.Actor
|
||||
cachedAgentID string
|
||||
cachedProfile string
|
||||
identityLoaded bool
|
||||
|
||||
// loadTokenForProfile is the profile-scoped token loader. It is a package
|
||||
// variable so profile-switch Actor attribution can be tested deterministically
|
||||
// without touching the OS keychain.
|
||||
loadTokenForProfile = auth.LoadTokenDataForProfile
|
||||
)
|
||||
|
||||
// setupAuditSink builds the process-wide audit sink once and caches it so the
|
||||
// runner and the shutdown hook share a single writer/forwarder instance.
|
||||
func setupAuditSink() audit.Sink {
|
||||
auditSinkOnce.Do(func() {
|
||||
sink, err := audit.BuildSink(defaultConfigDir(), auditReport)
|
||||
if err != nil {
|
||||
auditReport("initialization failed, audit disabled for this session: %v", err)
|
||||
sharedAuditSink = audit.NopSink{}
|
||||
return
|
||||
}
|
||||
sharedAuditSink = sink
|
||||
})
|
||||
return sharedAuditSink
|
||||
}
|
||||
|
||||
// CloseAuditSink flushes in-flight remote forwards and closes the audit writer.
|
||||
// It is invoked from an unconditional defer in Execute so the drain happens for
|
||||
// both successful and failed commands (Cobra skips PersistentPostRunE when RunE
|
||||
// returns an error). The sync.Once makes repeated calls safe.
|
||||
func CloseAuditSink() {
|
||||
auditCloseOnce.Do(func() {
|
||||
if sharedAuditSink == nil {
|
||||
return
|
||||
}
|
||||
if err := sharedAuditSink.Close(); err != nil {
|
||||
auditReport("close failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// auditReport routes non-fatal audit-subsystem diagnostics to the structured
|
||||
// file log (always, when available) and to stderr when DWS_AUDIT_DEBUG is set,
|
||||
// so init/write/forward failures are observable instead of silently swallowed.
|
||||
func auditReport(format string, args ...any) {
|
||||
msg := "audit: " + fmt.Sprintf(format, args...)
|
||||
if l := FileLoggerInstance(); l != nil {
|
||||
l.Warn(msg)
|
||||
}
|
||||
if audit.DebugEnabled() {
|
||||
fmt.Fprintln(os.Stderr, "[dws] "+msg)
|
||||
}
|
||||
}
|
||||
|
||||
// auditIdentity resolves the Actor for the active runtime profile. The result
|
||||
// is cached per-profile so a profile switch within a long-running process (e.g.
|
||||
// serve mode) re-resolves rather than reusing a stale identity.
|
||||
func auditIdentity() (audit.Actor, string) {
|
||||
profile := auth.RuntimeProfile()
|
||||
|
||||
auditIDMu.Lock()
|
||||
defer auditIDMu.Unlock()
|
||||
if identityLoaded && profile == cachedProfile {
|
||||
return cachedActor, cachedAgentID
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
var actor audit.Actor
|
||||
if td, err := loadTokenForProfile(configDir, profile); err == nil && td != nil {
|
||||
actor = audit.Actor{
|
||||
UserID: td.UserID,
|
||||
Name: td.UserName,
|
||||
CorpID: td.CorpID,
|
||||
CorpName: td.CorpName,
|
||||
}
|
||||
} else if err != nil {
|
||||
auditReport("resolve actor for profile %q failed: %v", profile, err)
|
||||
}
|
||||
|
||||
agentID := ""
|
||||
if id := auth.Load(configDir); id != nil {
|
||||
agentID = id.AgentID
|
||||
}
|
||||
|
||||
cachedActor, cachedAgentID, cachedProfile, identityLoaded = actor, agentID, profile, true
|
||||
return actor, agentID
|
||||
}
|
||||
|
||||
func emitAudit(sink audit.Sink, execID string, invokeStart time.Time, invocation executor.Invocation, endpoint string, retErr error, cliVersion string) {
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := sink.(audit.NopSink); ok {
|
||||
return
|
||||
}
|
||||
|
||||
actor, agentID := auditIdentity()
|
||||
|
||||
result := "success"
|
||||
var errCat, errReason string
|
||||
if retErr != nil {
|
||||
result = "error"
|
||||
errCat, errReason = classifyAuditError(retErr)
|
||||
}
|
||||
|
||||
paramsSummary := logging.SanitizeArguments(invocation.Params, 1024)
|
||||
|
||||
evt := &audit.Event{
|
||||
Timestamp: invokeStart,
|
||||
ExecutionID: execID,
|
||||
AgentID: agentID,
|
||||
Actor: actor,
|
||||
Product: invocation.CanonicalProduct,
|
||||
Command: invocation.Tool,
|
||||
Endpoint: transport.RedactURL(endpoint),
|
||||
ParamsSummary: paramsSummary,
|
||||
Result: result,
|
||||
ErrCategory: errCat,
|
||||
ErrReason: errReason,
|
||||
DurationMs: time.Since(invokeStart).Milliseconds(),
|
||||
CLIVersion: cliVersion,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
}
|
||||
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
auditReport("emit event failed (exec %s): %v", execID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyAuditError(err error) (category, reason string) {
|
||||
if err == nil {
|
||||
return "", ""
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
return string(typed.Category), typed.Reason
|
||||
}
|
||||
return "unknown", err.Error()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TestAuditIdentityReresolvesOnProfileSwitch guards the reviewer's finding that a
|
||||
// long-running process (e.g. serve mode) must attribute events to the ACTIVE
|
||||
// runtime profile rather than reusing a process-global first Actor. It also
|
||||
// asserts the per-profile cache avoids redundant token loads within one profile.
|
||||
func TestAuditIdentityReresolvesOnProfileSwitch(t *testing.T) {
|
||||
prevLoader := loadTokenForProfile
|
||||
prevProfile := auth.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
loadTokenForProfile = prevLoader
|
||||
auth.SetRuntimeProfile(prevProfile)
|
||||
resetAuditIdentityCache()
|
||||
})
|
||||
resetAuditIdentityCache()
|
||||
|
||||
var mu sync.Mutex
|
||||
calls := map[string]int{}
|
||||
loadTokenForProfile = func(_ /*configDir*/, profile string) (*auth.TokenData, error) {
|
||||
mu.Lock()
|
||||
calls[profile]++
|
||||
mu.Unlock()
|
||||
switch profile {
|
||||
case "orgA":
|
||||
return &auth.TokenData{UserID: "ua", UserName: "Alice", CorpID: "ca", CorpName: "CorpA"}, nil
|
||||
case "orgB":
|
||||
return &auth.TokenData{UserID: "ub", UserName: "Bob", CorpID: "cb", CorpName: "CorpB"}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
auth.SetRuntimeProfile("orgA")
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ua" || actor.CorpName != "CorpA" {
|
||||
t.Fatalf("orgA: got %+v, want Alice/CorpA", actor)
|
||||
}
|
||||
// Second call under the same profile must hit the cache (no extra load).
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ua" {
|
||||
t.Fatalf("orgA cached: got %+v", actor)
|
||||
}
|
||||
|
||||
auth.SetRuntimeProfile("orgB")
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ub" || actor.CorpName != "CorpB" {
|
||||
t.Fatalf("orgB: got %+v, want Bob/CorpB (stale Actor reused?)", actor)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls["orgA"] != 1 {
|
||||
t.Fatalf("orgA loaded %d times, want 1 (cache miss?)", calls["orgA"])
|
||||
}
|
||||
if calls["orgB"] != 1 {
|
||||
t.Fatalf("orgB loaded %d times, want 1", calls["orgB"])
|
||||
}
|
||||
}
|
||||
|
||||
func resetAuditIdentityCache() {
|
||||
auditIDMu.Lock()
|
||||
defer auditIDMu.Unlock()
|
||||
cachedActor = audit.Actor{}
|
||||
cachedAgentID = ""
|
||||
cachedProfile = ""
|
||||
identityLoaded = false
|
||||
}
|
||||
|
||||
// TestCloseAuditSinkDrainsOnErrorPath guards the reviewer's V5 finding: when a
|
||||
// command's RunE returns an error, Cobra skips PersistentPostRunE, so the audit
|
||||
// drain must instead happen through the unconditional defer in Execute that calls
|
||||
// CloseAuditSink. This test wires a real forwarder-backed sink into the shared
|
||||
// slot and asserts CloseAuditSink flushes the queued forward exactly as the
|
||||
// error-path defer would, and that a second call is a harmless no-op.
|
||||
func TestCloseAuditSinkDrainsOnErrorPath(t *testing.T) {
|
||||
var delivered int64
|
||||
var releaseOnce sync.Once
|
||||
release := make(chan struct{})
|
||||
releaseFn := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release // hold the request until the drain awaits it
|
||||
atomic.AddInt64(&delivered, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer releaseFn() // LIFO: unblock any in-flight handler before srv.Close()
|
||||
|
||||
writer, err := audit.NewDateRotatingWriter(t.TempDir(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fwd := audit.NewHTTPForwarder(srv.URL, "", audit.RedactNone, nil)
|
||||
sink := audit.NewFileSink(writer, audit.NewChain(""), fwd)
|
||||
|
||||
prevSink := sharedAuditSink
|
||||
t.Cleanup(func() {
|
||||
sharedAuditSink = prevSink
|
||||
auditCloseOnce = sync.Once{}
|
||||
})
|
||||
sharedAuditSink = sink
|
||||
auditCloseOnce = sync.Once{}
|
||||
|
||||
if err := sink.Emit(&audit.Event{Timestamp: time.Unix(0, 0), Product: "calendar", Command: "event_list", Result: "error"}); err != nil {
|
||||
t.Fatalf("emit: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&delivered); got != 0 {
|
||||
t.Fatalf("forward delivered before drain: %d", got)
|
||||
}
|
||||
|
||||
// Let the held request complete, then drain via the same entry point the
|
||||
// error-path defer uses. CloseAuditSink blocks until the forward goroutine
|
||||
// observes the HTTP response, so the counter is settled when it returns.
|
||||
releaseFn()
|
||||
CloseAuditSink()
|
||||
|
||||
if got := atomic.LoadInt64(&delivered); got != 1 {
|
||||
t.Fatalf("forward not drained on error path: delivered=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Idempotent: the success-path PersistentPostRunE and the defer both call it.
|
||||
CloseAuditSink()
|
||||
}
|
||||
+375
-61
@@ -39,11 +39,12 @@ import (
|
||||
)
|
||||
|
||||
type authLoginConfig struct {
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
TargetCorpID string
|
||||
}
|
||||
|
||||
type authLoginGuideAction string
|
||||
@@ -81,6 +82,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
newAuthMigrateKeychainCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
@@ -109,10 +111,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 本机登录后选择推荐/全部权限与授权业务域
|
||||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -154,8 +157,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = provider.Login(loginCtx, cfg.Force)
|
||||
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
||||
}
|
||||
@@ -163,6 +167,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
|
||||
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
runPostLoginAuthorization := func() error {
|
||||
@@ -217,7 +226,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAuthLoginJSON(w, tokenData, cfg.Force)
|
||||
return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg))
|
||||
}
|
||||
|
||||
// Default table output
|
||||
@@ -225,7 +234,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
|
||||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
||||
} else {
|
||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||
@@ -250,7 +259,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
|
||||
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
|
||||
@@ -275,6 +284,7 @@ var (
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
@@ -373,62 +383,72 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||
}
|
||||
|
||||
func newAuthLogoutCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "清除认证信息",
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "清除认证信息(默认退出所有组织)",
|
||||
Long: `清除本机钉钉登录态。
|
||||
|
||||
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
|
||||
Example: ` dws auth logout
|
||||
dws auth logout --profile <corpId>
|
||||
dws auth logout --profile "钉钉"`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
profileSelector, err := cmd.Flags().GetString("profile")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --profile")
|
||||
}
|
||||
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||||
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 strings.TrimSpace(profileSelector) != "" {
|
||||
if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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, "[OK] 已清除认证信息")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthStatusCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看认证状态",
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看认证状态",
|
||||
Long: `查看当前或指定组织 profile 的认证状态。
|
||||
|
||||
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
|
||||
Example: ` dws auth status
|
||||
dws auth status --profile <corpId>
|
||||
dws auth status --profile "钉钉"
|
||||
dws auth status --profile <corpId> --format json`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
profileSelector, err := cmd.Flags().GetString("profile")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --profile")
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(profileSelector)
|
||||
defer restoreProfile()
|
||||
|
||||
authenticated := false
|
||||
refreshed := false
|
||||
var tokenData *authpkg.TokenData
|
||||
var statusErr error
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
if data, err := provider.Status(); err == nil {
|
||||
@@ -444,17 +464,22 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
_ = authpkg.DeleteTokenData(configDir)
|
||||
} else if tokenData != nil {
|
||||
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
authenticated = true
|
||||
}
|
||||
} else {
|
||||
statusErr = err
|
||||
}
|
||||
diagnostic := authStatusDiagnosticFromError(statusErr)
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
|
||||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
@@ -467,6 +492,12 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||||
}
|
||||
if tokenData != nil {
|
||||
if tokenData.CorpName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||||
}
|
||||
if tokenData.CorpID != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||||
}
|
||||
if tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
|
||||
} else {
|
||||
@@ -478,13 +509,125 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
if diagnostic != nil {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message)
|
||||
fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint)
|
||||
} else if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate-keychain",
|
||||
Short: "将 macOS 系统 Keychain 登录态安全迁移到 file-DEK",
|
||||
Long: `将 dws-cli 的 legacy 与 profile 登录 token 统一重加密为 file-DEK,使 Codex 等沙箱进程与普通终端共享同一登录态。
|
||||
|
||||
迁移必须从仍可读取原登录态的系统 Keychain 模式运行。命令会先验证全部认证密文;任何认证条目不可解密时均不会写入。应用密钥等无关条目不在迁移范围内。
|
||||
先用 --dry-run 预检,确认后加 --yes 执行。`,
|
||||
Example: ` env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := cmd.Flags().GetString("to")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --to")
|
||||
}
|
||||
if strings.TrimSpace(target) != "file-dek" {
|
||||
return apperrors.NewValidation("--to 当前仅支持 file-dek")
|
||||
}
|
||||
if os.Getenv(keychain.DisableKeychainEnv) != "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"迁移必须从系统 Keychain 模式运行;请使用 `env -u %s dws auth migrate-keychain --to file-dek ...`",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
|
||||
dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run")
|
||||
yes, _ := cmd.Root().PersistentFlags().GetBool("yes")
|
||||
if !dryRun && !yes {
|
||||
return apperrors.NewValidation("迁移会重加密全部本地登录 token;请先使用 --dry-run 预检,确认后加 --yes 执行")
|
||||
}
|
||||
count, err := migrateKeychainToFileDEK(defaultConfigDir(), dryRun)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("keychain migration failed: %v", err))
|
||||
}
|
||||
|
||||
result := struct {
|
||||
Success bool `json:"success"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Target string `json:"target"`
|
||||
Entries int `json:"entries"`
|
||||
}{Success: true, DryRun: dryRun, Target: "file-dek", Entries: count}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(result)
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "预检通过:%d 个本地认证条目可迁移到 file-DEK\n", count)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "迁移完成:%d 个本地认证条目已统一使用 file-DEK\n", count)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("to", "file-dek", "目标密钥后端(当前仅支持 file-dek)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(selector)
|
||||
defer restoreProfile()
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
||||
_ = authpkg.RevokeTokenRemote(ctx)
|
||||
restoreProfile()
|
||||
}
|
||||
}
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushRuntimeProfile(selector string) func() {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return func() {}
|
||||
}
|
||||
previous := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile(selector)
|
||||
return func() {
|
||||
authpkg.SetRuntimeProfile(previous)
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthExportCommand() *cobra.Command {
|
||||
@@ -514,7 +657,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
}
|
||||
if !authpkg.PortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
@@ -683,11 +826,12 @@ func newAuthResetCommand() *cobra.Command {
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
_ = authpkg.DeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -862,10 +1006,6 @@ func authLoginMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive)
|
||||
}
|
||||
@@ -934,10 +1074,7 @@ func clipRunes(value string, limit int) string {
|
||||
}
|
||||
|
||||
func clearCompatCache() {
|
||||
store := cacheStoreFromEnv()
|
||||
if store != nil {
|
||||
_ = os.RemoveAll(store.Root)
|
||||
}
|
||||
// Cache store removed; no-op in static endpoint mode.
|
||||
}
|
||||
|
||||
func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
@@ -958,18 +1095,153 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
yes := false
|
||||
profileSelector := ""
|
||||
if cmd.Root() != nil {
|
||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
||||
}
|
||||
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
|
||||
if err != nil {
|
||||
return authLoginConfig{}, err
|
||||
}
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
TargetCorpID: targetCorpID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return "", nil
|
||||
}
|
||||
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||
return strings.TrimSpace(profile.CorpID), nil
|
||||
}
|
||||
if strings.HasPrefix(selector, "ding") {
|
||||
return selector, nil
|
||||
}
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
|
||||
type contactProfileIdentity struct {
|
||||
CorpID string
|
||||
CorpName string
|
||||
UserID string
|
||||
UserName string
|
||||
}
|
||||
|
||||
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
|
||||
if caller == nil || data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
restoreProfile := pushRuntimeProfile(corpID)
|
||||
defer restoreProfile()
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
|
||||
"profile": corpID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, ok := contactProfileIdentityFromToolResult(result)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if identity.CorpID != "" && identity.CorpID != corpID {
|
||||
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
|
||||
}
|
||||
|
||||
updated := *data
|
||||
if identity.CorpName != "" {
|
||||
updated.CorpName = identity.CorpName
|
||||
}
|
||||
if identity.UserID != "" {
|
||||
updated.UserID = identity.UserID
|
||||
}
|
||||
if identity.UserName != "" {
|
||||
updated.UserName = identity.UserName
|
||||
}
|
||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||
return nil
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
|
||||
return err
|
||||
}
|
||||
*data = updated
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
|
||||
if result == nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
for _, block := range result.Content {
|
||||
if strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
|
||||
return identity, true
|
||||
}
|
||||
}
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
|
||||
var payload struct {
|
||||
Result []struct {
|
||||
OrgEmployeeModel struct {
|
||||
CorpID string `json:"corpId"`
|
||||
OrgName string `json:"orgName"`
|
||||
UserID string `json:"userId"`
|
||||
UserIDLower string `json:"userid"`
|
||||
OrgUserName string `json:"orgUserName"`
|
||||
Name string `json:"name"`
|
||||
} `json:"orgEmployeeModel"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
if len(payload.Result) == 0 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
org := payload.Result[0].OrgEmployeeModel
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
}
|
||||
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func authStatusAuthenticated(data *authpkg.TokenData) bool {
|
||||
if data == nil {
|
||||
return false
|
||||
@@ -995,6 +1267,8 @@ type authStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Refreshed bool `json:"refreshed,omitempty"`
|
||||
TokenValid bool `json:"token_valid,omitempty"`
|
||||
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
|
||||
@@ -1006,14 +1280,54 @@ type authStatusResponse struct {
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
|
||||
type authStatusDiagnostic struct {
|
||||
Reason string
|
||||
Message string
|
||||
Hint string
|
||||
}
|
||||
|
||||
func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if keychain.IsCiphertextKeyMismatch(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "ciphertext_key_mismatch",
|
||||
Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证",
|
||||
Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。",
|
||||
}
|
||||
}
|
||||
if keychain.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
|
||||
}
|
||||
}
|
||||
if !keychain.IsUnavailable(err) {
|
||||
return nil
|
||||
}
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态",
|
||||
Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
|
||||
resp := authStatusResponse{
|
||||
Success: true,
|
||||
Authenticated: authenticated,
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
resp.Message = "未登录"
|
||||
if diagnostic != nil {
|
||||
resp.Message = diagnostic.Message
|
||||
resp.Reason = diagnostic.Reason
|
||||
resp.Hint = diagnostic.Hint
|
||||
} else {
|
||||
resp.Message = "未登录"
|
||||
}
|
||||
} else if data != nil {
|
||||
resp.Refreshed = refreshed
|
||||
resp.TokenValid = data.IsAccessTokenValid()
|
||||
|
||||
@@ -16,7 +16,10 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -132,6 +135,122 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false, want true; response=%+v", resp)
|
||||
}
|
||||
if resp.Authenticated {
|
||||
t.Fatalf("authenticated = true, want false; response=%+v", resp)
|
||||
}
|
||||
if resp.Reason != "keychain_unavailable" {
|
||||
t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") {
|
||||
t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false, want true; response=%+v", resp)
|
||||
}
|
||||
if resp.Authenticated {
|
||||
t.Fatalf("authenticated = true, want false; response=%+v", resp)
|
||||
}
|
||||
if resp.Reason != "dek_missing" {
|
||||
t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "登录密钥") {
|
||||
t.Fatalf("message should mention 登录密钥; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "dws auth reset") {
|
||||
t.Fatalf("hint should mention dws auth reset; response=%+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
|
||||
diagnostic := authStatusDiagnosticFromError(fmt.Errorf("load token: %w", keychain.ErrCiphertextKeyMismatch))
|
||||
if diagnostic == nil {
|
||||
t.Fatal("authStatusDiagnosticFromError() = nil")
|
||||
}
|
||||
if diagnostic.Reason != "ciphertext_key_mismatch" {
|
||||
t.Fatalf("reason = %q, want ciphertext_key_mismatch", diagnostic.Reason)
|
||||
}
|
||||
if !strings.Contains(diagnostic.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s: %q", keychain.DisableKeychainEnv, diagnostic.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
@@ -184,6 +303,246 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"corp_primary org", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
|
||||
t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainDryRunAndConfirmedExecution(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
|
||||
calls := 0
|
||||
migrateKeychainToFileDEK = func(_ string, dryRun bool) (int, error) {
|
||||
calls++
|
||||
if calls == 1 && !dryRun {
|
||||
t.Fatal("first migration call should be dry-run")
|
||||
}
|
||||
if calls == 2 && dryRun {
|
||||
t.Fatal("second migration call should execute")
|
||||
}
|
||||
return 4, nil
|
||||
}
|
||||
|
||||
newRoot := func() (*cobra.Command, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
return root, &out
|
||||
}
|
||||
|
||||
root, out := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --dry-run error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":true`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("dry-run output = %q", out.String())
|
||||
}
|
||||
|
||||
root, out = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --yes error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":false`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("migration output = %q", out.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("migration calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainRequiresConfirmationAndSystemMode(t *testing.T) {
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
migrateKeychainToFileDEK = func(_ string, _ bool) (int, error) {
|
||||
t.Fatal("migration backend should not be called")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
newRoot := func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
return root
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
root := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("unconfirmed migration error = %v, want --yes guidance", err)
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "env -u") {
|
||||
t.Fatalf("file-DEK mode migration error = %v, want system-mode guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: "client-app",
|
||||
ClientSecret: authpkg.PlainSecret("secret-app"),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
|
||||
t.Fatalf("profiles after logout = %#v, want empty", cfg)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("primary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("secondary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychain() {
|
||||
t.Fatal("legacy auth-token mirror should be deleted")
|
||||
}
|
||||
appConfig, err := authpkg.LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig() error = %v", err)
|
||||
}
|
||||
if appConfig == nil || appConfig.ClientID != "client-app" {
|
||||
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
|
||||
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("selected primary profile token should be deleted")
|
||||
}
|
||||
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("unselected secondary profile token should be retained")
|
||||
}
|
||||
loaded, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
|
||||
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
|
||||
newRoot := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
@@ -297,6 +656,15 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
}
|
||||
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
|
||||
t.Fatal("Force=false should still force authorization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
@@ -578,6 +946,53 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
token := &authpkg.TokenData{
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "ding32fff839a3e0105d",
|
||||
ClientID: "client-id",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
|
||||
}}
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
|
||||
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
|
||||
}
|
||||
if token.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
|
||||
}
|
||||
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
|
||||
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
|
||||
}
|
||||
|
||||
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
|
||||
}
|
||||
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
|
||||
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
|
||||
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -613,6 +1028,10 @@ func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) Fields() string { return "" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) JQ() string { return "" }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
if got == nil {
|
||||
return len(want) == 0
|
||||
@@ -642,3 +1061,41 @@ func stringSliceArgEqual(got any, want []string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
t.Cleanup(func() {
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
})
|
||||
|
||||
for _, token := range tokens {
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
|
||||
}
|
||||
}
|
||||
return configDir
|
||||
}
|
||||
|
||||
func authLogoutTestToken(corpID string) *authpkg.TokenData {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "access-" + corpID,
|
||||
RefreshToken: "refresh-" + corpID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: corpID + " org",
|
||||
UserID: "user-" + corpID,
|
||||
UserName: "User " + corpID,
|
||||
ClientID: "client-" + corpID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cacheCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
func newCacheCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cache",
|
||||
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
for _, name := range []string{"refresh", "status", "clean"} {
|
||||
sub := &cobra.Command{
|
||||
Use: name,
|
||||
Short: "已弃用:静态端点模式无需服务发现缓存",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, name)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
notice := cacheCompatNotice{
|
||||
Status: "deprecated",
|
||||
Command: "dws cache " + command,
|
||||
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
|
||||
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, err := json.MarshalIndent(notice, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return err
|
||||
default:
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
|
||||
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
|
||||
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
|
||||
// named after the reserved --params flag) aborted every invocation. With no
|
||||
// on-disk cache to quarantine it must degrade to an inert stub instead.
|
||||
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if cmd == nil || cmd.Name() != "mcp" {
|
||||
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
|
||||
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
|
||||
// the build panics AND a discovery cache exists on disk, the partition is
|
||||
// quarantined and the build retried once, so a fixed binary escapes the
|
||||
// lock-out with zero manual cache surgery.
|
||||
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if cmd == nil || cmd.Short != "rebuilt-probe" {
|
||||
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
|
||||
}
|
||||
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
||||
if len(quarantines) != 1 {
|
||||
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
||||
}
|
||||
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
||||
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
|
||||
// if the rebuild after quarantine panics again, the stub is returned and the
|
||||
// `dws cache refresh` hint kept.
|
||||
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
var cmd *cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != "mcp" {
|
||||
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
|
||||
// on the happy path.
|
||||
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
|
||||
orig := buildMCPCommandFn
|
||||
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
|
||||
}
|
||||
t.Cleanup(func() { buildMCPCommandFn = orig })
|
||||
|
||||
cmd := newMCPCommand(context.Background(), nil, nil, nil)
|
||||
if cmd == nil || cmd.Short != "canonical-probe" {
|
||||
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCatalogCommand(_ cli.CatalogLoader) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "查看服务目录 (静态端点模式)",
|
||||
Hidden: true,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -57,12 +57,12 @@ func devappMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + devappServerPath
|
||||
}
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
func defaultPATServerDescriptor() mcptypes.ServerDescriptor {
|
||||
return mcptypes.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
DisplayName: defaultPATDisplayName,
|
||||
Endpoint: defaultPATMCPEndpoint(),
|
||||
CLI: market.CLIOverlay{
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: defaultPATProductID,
|
||||
Command: defaultPATProductID,
|
||||
Prefixes: []string{defaultPATProductID},
|
||||
@@ -104,7 +104,7 @@ func defaultPATGatewayBaseURL() string {
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []market.ServerDescriptor) {
|
||||
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
|
||||
@@ -167,7 +167,7 @@ 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) {
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
@@ -363,7 +363,7 @@ func DirectRuntimeProductIDs() map[string]bool {
|
||||
// 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) {
|
||||
func AppendDynamicServer(server mcptypes.ServerDescriptor) {
|
||||
dynamicMu.Lock()
|
||||
defer dynamicMu.Unlock()
|
||||
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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,198 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
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 TestDirectRuntimeProductIDsIncludesDevappHelper(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["devapp"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing devapp helper product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWithoutRegistry(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverridePreservesQuery(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp?key=secret")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp?key=secret")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://example.test/server/devapp-supplement",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "devapp",
|
||||
Command: "devapp",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement?key=secret",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-static",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp-env")
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement",
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp-env")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
}
|
||||
|
||||
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()
|
||||
previousAliases := dynamicAliases
|
||||
dynamicAliases = nil
|
||||
dynamicMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
dynamicMu.Lock()
|
||||
dynamicAliases = previousAliases
|
||||
dynamicMu.Unlock()
|
||||
})
|
||||
|
||||
cases := map[string]string{
|
||||
"tb": "teambition",
|
||||
"dingtalk-discovery": "discovery",
|
||||
"dingtalk-oa-plus": "oa",
|
||||
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
if got := normalizeDirectRuntimeProductID(input); got != want {
|
||||
t.Fatalf("normalizeDirectRuntimeProductID(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,7 @@ import (
|
||||
"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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -31,6 +30,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var doctorKeychainDiagnose = keychain.Diagnose
|
||||
|
||||
// checkStatus represents the outcome of a single doctor check.
|
||||
type checkStatus string
|
||||
|
||||
@@ -78,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
|
||||
checks = append(checks, authResult)
|
||||
|
||||
keychainResult := doctorCheckKeychain(w, jsonOut)
|
||||
checks = append(checks, keychainResult)
|
||||
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
@@ -134,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
|
||||
data, err := provider.Status()
|
||||
if err != nil || data == nil {
|
||||
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusFail,
|
||||
Message: diagnostic.Message,
|
||||
Hint: diagnostic.Hint,
|
||||
Detail: map[string]string{"reason": diagnostic.Reason},
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
|
||||
if !edition.Get().IsEmbedded {
|
||||
r.Hint = "运行 dws auth login 进行登录"
|
||||
@@ -184,6 +201,40 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Keychain check ─────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
|
||||
}
|
||||
|
||||
diagnostic := doctorKeychainDiagnose()
|
||||
r := checkResult{
|
||||
Name: "keychain",
|
||||
Status: statusPass,
|
||||
Message: diagnostic.Message,
|
||||
Detail: diagnostic.Detail,
|
||||
}
|
||||
if !diagnostic.OK {
|
||||
r.Status = statusFail
|
||||
r.Hint = diagnostic.Hint
|
||||
if diagnostic.Detail == nil {
|
||||
r.Detail = map[string]string{"reason": diagnostic.Reason}
|
||||
} else if diagnostic.Reason != "" {
|
||||
detail := make(map[string]string, len(diagnostic.Detail)+1)
|
||||
for k, v := range diagnostic.Detail {
|
||||
detail[k] = v
|
||||
}
|
||||
detail["reason"] = diagnostic.Reason
|
||||
r.Detail = detail
|
||||
}
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Network check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
@@ -193,15 +244,12 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
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)
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL, nil)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
@@ -215,6 +263,22 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
return r
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
|
||||
Hint: "请检查网络连接或代理设置",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusPass,
|
||||
@@ -233,64 +297,10 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
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,
|
||||
Message: "静态端点模式, 无需缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
|
||||
@@ -15,9 +15,16 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCountResults(t *testing.T) {
|
||||
@@ -108,11 +115,8 @@ func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
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)
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,14 +126,116 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusWarn {
|
||||
t.Errorf("expected warn for empty cache, got %s", r.Status)
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckAuth(context.Background(), &buf, false)
|
||||
|
||||
if r.Name != "auth" {
|
||||
t.Fatalf("name = %q, want auth", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") {
|
||||
t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckAuth(context.Background(), &buf, false)
|
||||
|
||||
if r.Name != "auth" {
|
||||
t.Fatalf("name = %q, want auth", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "登录密钥") {
|
||||
t.Fatalf("message should mention 登录密钥; result=%+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; result=%+v", r)
|
||||
}
|
||||
detail, ok := r.Detail.(map[string]string)
|
||||
if !ok || detail["reason"] != "dek_missing" {
|
||||
t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) {
|
||||
prev := doctorKeychainDiagnose
|
||||
doctorKeychainDiagnose = func() keychain.Diagnostic {
|
||||
return keychain.Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "macOS 默认钥匙串不存在",
|
||||
Hint: "恢复默认钥匙串后重试",
|
||||
Detail: map[string]string{
|
||||
"default_keychain": "/tmp/missing.keychain-db",
|
||||
},
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
doctorKeychainDiagnose = prev
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckKeychain(&buf, false)
|
||||
|
||||
if r.Name != "keychain" {
|
||||
t.Fatalf("name = %q, want keychain", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if r.Message != "macOS 默认钥匙串不存在" {
|
||||
t.Fatalf("message = %q", r.Message)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint is empty; result=%+v", r)
|
||||
}
|
||||
detail, ok := r.Detail.(map[string]string)
|
||||
if !ok || detail["default_keychain"] == "" {
|
||||
t.Fatalf("detail = %#v, want default_keychain", r.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCommandStructure(t *testing.T) {
|
||||
cmd := newDoctorCommand()
|
||||
if cmd.Use != "doctor" {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
|
||||
runner := &countingErrorRunner{}
|
||||
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
|
||||
result, err := caller.CallTool(context.Background(), "aitable-helper", "set_advanced_permission", map[string]any{"enabled": false})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool() error = %v", err)
|
||||
}
|
||||
if got := runner.calls.Load(); got != 0 {
|
||||
t.Fatalf("runner calls = %d, want 0", got)
|
||||
}
|
||||
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"dry_run":true`) {
|
||||
t.Fatalf("dry-run result = %#v", result)
|
||||
}
|
||||
|
||||
var nilAdapter *toolCallerAdapter
|
||||
if nilAdapter.DryRun() || nilAdapter.Format() != "json" {
|
||||
t.Fatal("nil adapter accessors are not safe")
|
||||
}
|
||||
if _, err := nilAdapter.CallTool(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil adapter accepted a tool call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
|
||||
fallback := &countingErrorRunner{}
|
||||
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
|
||||
result, err := runner.Run(context.Background(), executor.NewHelperInvocation(
|
||||
"test",
|
||||
"aitable",
|
||||
"tool",
|
||||
map[string]any{"id": "x"},
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !result.Invocation.DryRun || result.Response["dry_run"] != true {
|
||||
t.Fatalf("dry-run result = %#v", result)
|
||||
}
|
||||
if got := fallback.calls.Load(); got != 0 {
|
||||
t.Fatalf("fallback calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
type countingErrorRunner struct {
|
||||
calls atomic.Int64
|
||||
}
|
||||
|
||||
func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
r.calls.Add(1)
|
||||
return executor.Result{}, errors.New("runner must not be called")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
)
|
||||
|
||||
func writeEventTestAppConfig(t *testing.T, dir string, cfg authpkg.AppConfig) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal app config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(authpkg.GetAppConfigPath(dir), raw, 0o600); err != nil {
|
||||
t.Fatalf("write app config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventCredentials_PortalNormalAllowsMissingClientSecret(t *testing.T) {
|
||||
t.Setenv(authpkg.EnvClientID, "")
|
||||
t.Setenv(authpkg.EnvClientSecret, "")
|
||||
dir := t.TempDir()
|
||||
|
||||
clientID, clientSecret, err := resolveEventCredentials(dir, eventStreamTicketOptions{
|
||||
Mode: source.PortalTicketModeNormal,
|
||||
SourceID: "pre_open_source",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEventCredentials: %v", err)
|
||||
}
|
||||
if clientID != "portal-ticket-normal:pre_open_source" {
|
||||
t.Fatalf("clientID = %q, want portal-ticket-normal:pre_open_source", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
t.Fatalf("clientSecret = %q, want empty", clientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventCredentials_PortalCustomStillRequiresClientSecret(t *testing.T) {
|
||||
t.Setenv(authpkg.EnvClientID, "")
|
||||
t.Setenv(authpkg.EnvClientSecret, "")
|
||||
dir := t.TempDir()
|
||||
writeEventTestAppConfig(t, dir, authpkg.AppConfig{ClientID: "ding-custom"})
|
||||
|
||||
_, _, err := resolveEventCredentials(dir, eventStreamTicketOptions{
|
||||
Mode: source.PortalTicketModeCustom,
|
||||
})
|
||||
if !errors.Is(err, authpkg.ErrClientSecretEmpty) {
|
||||
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
// 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"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type commonConsumeOptions struct {
|
||||
EventTypes []string
|
||||
Filter string
|
||||
Compact bool
|
||||
FormatRaw string
|
||||
OutputDir string
|
||||
RoutesRaw []string
|
||||
MaxEvents int
|
||||
Duration time.Duration
|
||||
Quiet bool
|
||||
Force bool
|
||||
DryRun bool
|
||||
Foreground bool
|
||||
}
|
||||
|
||||
type personalConsumeOptions struct {
|
||||
Common commonConsumeOptions
|
||||
EventKey string
|
||||
DebugRawEvents bool
|
||||
SubscribeID string
|
||||
Rule string
|
||||
Name string
|
||||
FilterJSON string
|
||||
QueryCSV string
|
||||
TTL time.Duration
|
||||
Ephemeral bool
|
||||
UserID string
|
||||
GroupID string
|
||||
ControlBaseURL string
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalListOptions struct {
|
||||
Category string
|
||||
EnabledOnly bool
|
||||
IncludePending bool
|
||||
Format string
|
||||
}
|
||||
|
||||
type personalStatusOptions struct {
|
||||
EventKey string
|
||||
Status string
|
||||
SubscribeID string
|
||||
Format string
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalStopOptions struct {
|
||||
SubscribeID string
|
||||
All bool
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalStreamSourceOptions struct {
|
||||
ConfigDir string
|
||||
Identity personal.Identity
|
||||
TicketMode string
|
||||
TicketURL string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
func newEventSchemaCommand() *cobra.Command {
|
||||
var asIdentity string
|
||||
var formatRaw string
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <event_key>",
|
||||
Short: "显示事件 schema",
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as != "user" {
|
||||
return fmt.Errorf("event schema is only supported with --as user")
|
||||
}
|
||||
def, ok := personal.Lookup(args[0])
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown personal event key %q", args[0])
|
||||
}
|
||||
if !def.Public {
|
||||
return personal.PublicAvailabilityError(args[0])
|
||||
}
|
||||
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
|
||||
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要查询 payload 字段定义的个人事件码",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
|
||||
items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending)
|
||||
if opts.Format == "json" {
|
||||
enc := json.NewEncoder(c.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(items)
|
||||
}
|
||||
tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION")
|
||||
for _, it := range items {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
it.EventKey, it.RuleType, it.Status, it.Description)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
if format != "json" {
|
||||
return fmt.Errorf("event schema only supports json output")
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(personal.BuildSchemaDocument(def))
|
||||
}
|
||||
|
||||
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
}
|
||||
normalised, fellback := consume.NormalizeFormat(rawFormat)
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
|
||||
if opts.Common.DryRun {
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
DryRun: true,
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||
return consume.Run(ctx, cfg)
|
||||
}
|
||||
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if sub.SubscribeID == "" {
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||
}
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
EventKey: eventKey,
|
||||
RuleType: ruleType,
|
||||
ClientID: identity.ClientID,
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
workDir, filepath.Join(workDir, "bus.log"))
|
||||
}
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
if !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = bus.Run(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = consume.Run(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if opts.DebugRawEvents {
|
||||
cfg.EventTypes = nil
|
||||
cfg.Filter = ""
|
||||
cfg.SubscribeID = ""
|
||||
return
|
||||
}
|
||||
cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes)
|
||||
cfg.Filter = opts.Common.Filter
|
||||
cfg.SubscribeID = strings.TrimSpace(subscribeID)
|
||||
}
|
||||
|
||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
|
||||
if eventKey == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(eventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
|
||||
if ruleType == "" {
|
||||
if def, ok := personal.Lookup(eventKey); ok {
|
||||
ruleType = def.RuleType
|
||||
}
|
||||
}
|
||||
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
|
||||
return sub, eventKey, ruleType, nil
|
||||
}
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
sub, err := client.CreateSubscription(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, opts.EventKey, ruleType, nil
|
||||
}
|
||||
|
||||
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
var qs busctl.EntryStatus
|
||||
if entry != nil {
|
||||
qs = busctl.QueryEntry(*entry)
|
||||
} else {
|
||||
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
|
||||
WorkDir: workDir,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
ClientIDHash: identityHash,
|
||||
IdentityHash: identityHash,
|
||||
State: busctl.BusStateNotRunning,
|
||||
Meta: &bus.Meta{
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
},
|
||||
}}
|
||||
}
|
||||
status := opts.Status
|
||||
if status == "" || status == "all" {
|
||||
status = ""
|
||||
}
|
||||
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
|
||||
Status: status,
|
||||
EventKey: opts.EventKey,
|
||||
SubscribeID: opts.SubscribeID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
if opts.Format == "json" {
|
||||
enc := json.NewEncoder(c.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]any{
|
||||
"identity": redactedPersonalIdentity(identity, identityHash),
|
||||
"subscriptions": subs,
|
||||
"bus": qs,
|
||||
})
|
||||
}
|
||||
renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePublicPersonalEvent(eventKey string) error {
|
||||
eventKey = strings.TrimSpace(eventKey)
|
||||
if eventKey == "" {
|
||||
return nil
|
||||
}
|
||||
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
|
||||
return personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) {
|
||||
fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n",
|
||||
displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash)
|
||||
fmt.Fprintf(w, "Bus: %s", qs.Entry.State)
|
||||
if qs.Entry.HolderPID > 0 {
|
||||
fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID)
|
||||
}
|
||||
fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir)
|
||||
if len(subs) == 0 {
|
||||
fmt.Fprintln(w, "Subscriptions: none")
|
||||
} else {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE")
|
||||
for _, sub := range subs {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
renderPersonalConsumers(w, qs)
|
||||
}
|
||||
|
||||
func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) {
|
||||
if qs.Entry.State != busctl.BusStateRunning {
|
||||
fmt.Fprintln(w, "Consumers: none")
|
||||
return
|
||||
}
|
||||
if qs.Live == nil {
|
||||
fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)")
|
||||
return
|
||||
}
|
||||
if len(qs.Live.Consumers) == 0 {
|
||||
fmt.Fprintln(w, "Consumers: none")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Consumers:")
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED")
|
||||
for _, cs := range qs.Live.Consumers {
|
||||
eventKeys := strings.Join(cs.EventTypes, ",")
|
||||
if eventKeys == "" {
|
||||
eventKeys = "(catch-all)"
|
||||
}
|
||||
subscribeID := displayPersonalStatusValue(cs.SubscribeID)
|
||||
filter := displayPersonalStatusValue(cs.Filter)
|
||||
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n",
|
||||
cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func displayPersonalStatusValue(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "-"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
ctx := c.Context()
|
||||
explicitSubscribeID := strings.TrimSpace(opts.SubscribeID)
|
||||
isSingleTarget := explicitSubscribeID != ""
|
||||
if explicitSubscribeID != "" && opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if explicitSubscribeID == "" && !opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
for _, id := range subscribeIDs {
|
||||
if err := client.DeleteSubscription(ctx, id); err != nil {
|
||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
||||
}
|
||||
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
||||
}
|
||||
|
||||
remaining, err := personal.LoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running")
|
||||
return nil
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
|
||||
explicit = strings.TrimSpace(explicit)
|
||||
if explicit != "" && all {
|
||||
return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if explicit != "" {
|
||||
return []string{explicit}, nil
|
||||
}
|
||||
if !all {
|
||||
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
|
||||
}
|
||||
states, err := personal.LoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(states))
|
||||
for _, st := range states {
|
||||
if st.SubscribeID != "" {
|
||||
ids = append(ids, st.SubscribeID)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error {
|
||||
targets := make(map[string]struct{}, len(subscribeIDs))
|
||||
for _, id := range subscribeIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targets[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if ipcEndpoint == "" || len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
status, err := busctl.QueryStatus(ipcEndpoint)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
signalled := make(map[int]struct{})
|
||||
for _, consumer := range status.Consumers {
|
||||
if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok {
|
||||
continue
|
||||
}
|
||||
if consumer.PID <= 0 || consumer.PID == os.Getpid() {
|
||||
continue
|
||||
}
|
||||
if _, ok := signalled[consumer.PID]; ok {
|
||||
continue
|
||||
}
|
||||
proc, err := os.FindProcess(consumer.PID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
signalled[consumer.PID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
|
||||
if single && len(subscribeIDs) == 1 {
|
||||
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
||||
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
return personal.Identity{}, err
|
||||
}
|
||||
tokenData, _ := authpkg.LoadTokenData(configDir)
|
||||
var corpID, userID, clientID, refreshToken string
|
||||
if tokenData != nil {
|
||||
corpID = tokenData.CorpID
|
||||
userID = tokenData.UserID
|
||||
clientID = tokenData.ClientID
|
||||
refreshToken = tokenData.RefreshToken
|
||||
}
|
||||
if corpID == "" {
|
||||
corpID = resolveRuntimeDefault(ctx, "$corpId")
|
||||
}
|
||||
if userID == "" {
|
||||
userID = resolveRuntimeDefault(ctx, "$currentUserId")
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = authpkg.ClientID()
|
||||
}
|
||||
if clientID == "" {
|
||||
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
|
||||
clientID = id
|
||||
}
|
||||
}
|
||||
if clientID == "" {
|
||||
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
|
||||
}
|
||||
sourceID := strings.TrimSpace(sourceIDOverride)
|
||||
if sourceID == "" {
|
||||
sourceID = personalEventStreamSourceID("")
|
||||
}
|
||||
localSubject := ""
|
||||
if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" {
|
||||
localSubject = personalTokenSubject("refresh", refreshToken)
|
||||
if localSubject == "" {
|
||||
localSubject = personalTokenSubject("access", accessToken)
|
||||
}
|
||||
}
|
||||
return personal.Identity{
|
||||
AccessToken: accessToken,
|
||||
LocalSubject: localSubject,
|
||||
CorpID: corpID,
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
SourceID: sourceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func personalTokenSubject(kind, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func resolveRuntimeDefault(ctx context.Context, key string) string {
|
||||
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
|
||||
if fn := fnMap()[key]; fn != nil {
|
||||
if v, ok := fn(ctx); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
mode := strings.TrimSpace(opts.TicketMode)
|
||||
if mode == "" {
|
||||
mode = "normal"
|
||||
}
|
||||
if mode != "normal" && mode != "custom" {
|
||||
return nil, fmt.Errorf("stream ticket mode must be normal or custom")
|
||||
}
|
||||
ticketURL := strings.TrimSpace(opts.TicketURL)
|
||||
if ticketURL == "" {
|
||||
ticketURL = personalEventStreamTicketURL("", opts.ConfigDir)
|
||||
}
|
||||
clientID := opts.Identity.ClientID
|
||||
clientSecret := ""
|
||||
if mode == "custom" {
|
||||
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.ClientIDOverride != "" {
|
||||
clientID = opts.ClientIDOverride
|
||||
} else if clientID == "" {
|
||||
clientID = resolvedID
|
||||
}
|
||||
clientSecret = secret
|
||||
}
|
||||
_ = ctx
|
||||
return source.NewPersonal(source.PersonalConfig{
|
||||
AccessToken: opts.Identity.AccessToken,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
TicketURL: ticketURL,
|
||||
TicketMode: mode,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
})
|
||||
}
|
||||
|
||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
|
||||
args := []string{
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
}
|
||||
// Forward the organization so the detached _bus child resolves
|
||||
// credentials for the SAME profile the parent used. Without this the
|
||||
// child falls back to the default profile's token slot and fails to
|
||||
// authenticate the personal stream for a non-default `--profile`
|
||||
// (symptom: "bus child reported startup failure on ready pipe", no
|
||||
// bus.log). --profile accepts a corpId; the root pre-parses it into the
|
||||
// runtime profile before the _bus handler resolves the identity.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", cid)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
}
|
||||
if strings.TrimSpace(ticketURL) != "" {
|
||||
args = append(args, "--stream-ticket-url", ticketURL)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func personalEventTypes(eventKey string, explicit []string) []string {
|
||||
if len(explicit) > 0 {
|
||||
return explicit
|
||||
}
|
||||
if strings.TrimSpace(eventKey) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{eventKey}
|
||||
}
|
||||
|
||||
func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string {
|
||||
return map[string]string{
|
||||
"corp_id": displayIdentityPart(identity.CorpID),
|
||||
"user_id": displayIdentityPart(identity.UserID),
|
||||
"client_id": identity.ClientID,
|
||||
"source_id": identity.SourceID,
|
||||
"identity_hash": identityHash,
|
||||
}
|
||||
}
|
||||
|
||||
func displayIdentityPart(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func firstNonEmptyPersonalString(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func personalEventControlBaseURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath
|
||||
}
|
||||
|
||||
func personalEventStreamTicketURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket"
|
||||
}
|
||||
|
||||
func personalEventStreamSourceID(raw string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
|
||||
return v
|
||||
}
|
||||
return "open"
|
||||
}
|
||||
|
||||
func personalEventMCPBaseURL(configDir string) string {
|
||||
if v := configuredMCPBaseURL(configDir); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return config.DefaultMCPBaseURL
|
||||
}
|
||||
|
||||
func configuredMCPBaseURL(configDir string) string {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
configDir = defaultConfigDir()
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
|
||||
cfg := consume.Config{}
|
||||
opts := personalConsumeOptions{
|
||||
DebugRawEvents: true,
|
||||
Common: commonConsumeOptions{
|
||||
EventTypes: []string{"should-not-survive"},
|
||||
Filter: "^should-not-survive$",
|
||||
},
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
|
||||
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
|
||||
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
|
||||
cfg := consume.Config{}
|
||||
opts := personalConsumeOptions{Common: commonConsumeOptions{Filter: "^user_im_"}}
|
||||
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
|
||||
if len(cfg.EventTypes) != 1 || cfg.EventTypes[0] != "user_im_message_receive_o2o" {
|
||||
t.Fatalf("eventTypes = %#v", cfg.EventTypes)
|
||||
}
|
||||
if cfg.Filter != "^user_im_" || cfg.SubscribeID != "sub-1" {
|
||||
t.Fatalf("filter=%q subscribeID=%q", cfg.Filter, cfg.SubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", "--debug-raw-events"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", personal.EventSingleChat})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
for _, name := range []string{"user", "group", "query"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("flag --%s is not registered", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"peer-user-id",
|
||||
"peer-union-id",
|
||||
"sender-user-id",
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
} {
|
||||
if cmd.Flags().Lookup(name) != nil {
|
||||
t.Fatalf("retired flag --%s is still registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"peer-user-id",
|
||||
"peer-union-id",
|
||||
"sender-user-id",
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--" + name, "x"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --"+name) {
|
||||
t.Fatalf("Execute() error = %v, want unknown flag", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--as", "app", "--user", "507971"},
|
||||
{"--as", "app", "--group", "cid"},
|
||||
{"--as", "app", "--query", "报警"},
|
||||
} {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute(%v) error = %v, want public availability guard", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// 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"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestResolvePersonalEventIdentityUsesCorpUserWhenAvailable(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
if identity.LocalSubject != "" {
|
||||
t.Fatalf("LocalSubject = %q, want empty when corp/user are available", identity.LocalSubject)
|
||||
}
|
||||
wantKey := "corp_user\x00corp-1\x00user-1\x00client-1\x00pre_open_source"
|
||||
if got := identity.Key(); got != wantKey {
|
||||
t.Fatalf("identity key = %q, want %q", got, wantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityFallsBackToRefreshTokenSubject(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
wantSubject := personalTokenSubject("refresh", "refresh-1")
|
||||
if identity.LocalSubject != wantSubject {
|
||||
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
|
||||
}
|
||||
if strings.Contains(identity.Key(), "refresh-1") || strings.Contains(identity.Key(), "access-1") {
|
||||
t.Fatalf("identity key leaked raw token: %q", identity.Key())
|
||||
}
|
||||
|
||||
body, err := json.Marshal(redactedPersonalIdentity(identity, "identity-hash-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal redacted identity: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), wantSubject) || strings.Contains(string(body), "refresh-1") || strings.Contains(string(body), "access-1") {
|
||||
t.Fatalf("redacted identity leaked local subject/token: %s", string(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "unknown") {
|
||||
t.Fatalf("redacted identity should mark missing corp/user as unknown: %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityFallsBackToAccessTokenSubject(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
wantSubject := personalTokenSubject("access", "access-1")
|
||||
if identity.LocalSubject != wantSubject {
|
||||
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, identity, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{WorkDir: "wd", State: busctl.BusStateNotRunning},
|
||||
})
|
||||
rendered := out.String()
|
||||
if !strings.Contains(rendered, "corp=unknown user=unknown") {
|
||||
t.Fatalf("status output = %q, want unknown corp/user", rendered)
|
||||
}
|
||||
if strings.Contains(rendered, wantSubject) || strings.Contains(rendered, "access-1") {
|
||||
t.Fatalf("status output leaked local subject/token: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityDefaultsSourceIDToOpen(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
if identity.SourceID != "open" {
|
||||
t.Fatalf("SourceID = %q, want open", identity.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventDefaultsUseProductionWithoutMCPConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
if got := personalEventControlBaseURL("", dir); got != "https://mcp.dingtalk.com/dws" {
|
||||
t.Fatalf("personalEventControlBaseURL() = %q, want production control URL", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL("", dir); got != "https://mcp.dingtalk.com/stream/connections/ticket" {
|
||||
t.Fatalf("personalEventStreamTicketURL() = %q, want production ticket URL", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID(""); got != "open" {
|
||||
t.Fatalf("personalEventStreamSourceID() = %q, want open", got)
|
||||
}
|
||||
if got := config.GetMCPBaseURL(); got != "https://mcp.dingtalk.com" {
|
||||
t.Fatalf("config.GetMCPBaseURL() = %q, want production MCP URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventDefaultsRespectExplicitAndMCPConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://custom-mcp.example.com\n"), 0o600); err != nil {
|
||||
t.Fatalf("write mcp_url: %v", err)
|
||||
}
|
||||
|
||||
if got := personalEventControlBaseURL("", dir); got != "https://custom-mcp.example.com/dws" {
|
||||
t.Fatalf("personalEventControlBaseURL() = %q, want configured control URL", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL("", dir); got != "https://custom-mcp.example.com/stream/connections/ticket" {
|
||||
t.Fatalf("personalEventStreamTicketURL() = %q, want configured ticket URL", got)
|
||||
}
|
||||
if got := personalEventControlBaseURL(" https://override.example.com/dws/ ", dir); got != "https://override.example.com/dws" {
|
||||
t.Fatalf("explicit control URL = %q, want trimmed override", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL(" https://override.example.com/ticket/ ", dir); got != "https://override.example.com/ticket" {
|
||||
t.Fatalf("explicit ticket URL = %q, want trimmed override", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
|
||||
t.Fatalf("explicit sourceID = %q, want flag_source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSourceIDPrefersEditionOverride(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{PersonalEventSourceID: "edition_source"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
if got := personalEventStreamSourceID(""); got != "edition_source" {
|
||||
t.Fatalf("personalEventStreamSourceID() = %q, want edition_source", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
|
||||
t.Fatalf("explicit sourceID = %q, want flag_source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func setupPersonalIdentityToken(t *testing.T, data *authpkg.TokenData) string {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal token data: %v", err)
|
||||
}
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(dir string) ([]byte, error) {
|
||||
if filepath.Clean(dir) != filepath.Clean(configDir) {
|
||||
t.Fatalf("LoadToken dir = %q, want %q", dir, configDir)
|
||||
}
|
||||
return raw, nil
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
return configDir
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// 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/internal/event/personal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "table", args: []string{"--as", "user"}},
|
||||
{name: "json", args: []string{"--as", "user", "--format", "json"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
assertPersonalOutputHidesSchemaIDs(t, got)
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventListDefaultsToUser(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
|
||||
t.Fatalf("list output = %s, want personal event catalog", got)
|
||||
}
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
}
|
||||
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
|
||||
t.Fatalf("list default appears to use legacy application output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventPublicHelpHidesAppMode(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
}{
|
||||
{name: "consume", cmd: newEventConsumeCommand()},
|
||||
{name: "list", cmd: newEventListCommand()},
|
||||
{name: "schema", cmd: newEventSchemaCommand()},
|
||||
{name: "status", cmd: newEventStatusCommand()},
|
||||
{name: "stop", cmd: newEventStopCommand()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
tc.cmd.SetOut(&out)
|
||||
tc.cmd.SetArgs([]string{"--help"})
|
||||
if tc.name == "schema" {
|
||||
tc.cmd.SetArgs([]string{personal.EventSingleChat, "--help"})
|
||||
}
|
||||
if err := tc.cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, hidden := range []string{"--as", "user|app", "应用事件" + " Stream"} {
|
||||
if strings.Contains(got, hidden) {
|
||||
t.Fatalf("%s help leaked %q:\n%s", tc.name, hidden, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventListAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--all are not supported for personal events") {
|
||||
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventAsAppRejected(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, cmd := range []*cobra.Command{
|
||||
newEventListCommand(),
|
||||
newEventStatusCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventStopCommand(),
|
||||
newEventSchemaCommand(),
|
||||
} {
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app"})
|
||||
if cmd.Use == "schema <event_key>" {
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--as", "app"})
|
||||
}
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("%s Execute() error = %v, want public availability guard", cmd.Use, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStatusAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
|
||||
cmd := newEventStatusCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--all", "--fail-on-orphan"})
|
||||
err := cmd.Execute()
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), "--all") ||
|
||||
!strings.Contains(err.Error(), "--fail-on-orphan") ||
|
||||
!strings.Contains(err.Error(), "not supported for personal events") {
|
||||
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "default", args: []string{personal.EventSingleChat, "--as", "user"}},
|
||||
{name: "json", args: []string{personal.EventSingleChat, "--as", "user", "--format", "json"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
assertPersonalOutputHidesSchemaIDs(t, out.String())
|
||||
if strings.Contains(out.String(), "Schemas") {
|
||||
t.Fatalf("schema output contains Schemas line: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
for _, eventKey := range []string{
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
personal.EventInChat,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output for %s is not JSON: %v\n%s", eventKey, err, got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"event_key",
|
||||
"display_name",
|
||||
"description",
|
||||
"category",
|
||||
"rule_type",
|
||||
"required_params",
|
||||
"jq_root_path",
|
||||
"schema",
|
||||
"event_id",
|
||||
"timestamp",
|
||||
"subscribe_id",
|
||||
"content",
|
||||
"sender",
|
||||
"sender_open_dingtalk_id",
|
||||
"conversation_id",
|
||||
"message_id",
|
||||
"create_time",
|
||||
"event_time",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{
|
||||
"message.text",
|
||||
"chat.openConversationId",
|
||||
"sender.userId",
|
||||
"sender.unionId",
|
||||
"auth",
|
||||
"resolved_output_schema",
|
||||
"decoded_data_schema",
|
||||
"filter_schema",
|
||||
"payload_schema",
|
||||
"output_schema",
|
||||
"data_json_path",
|
||||
"headers",
|
||||
"audit",
|
||||
"tenant",
|
||||
"subject",
|
||||
"traceId",
|
||||
"msgIdMetaq",
|
||||
"at_users",
|
||||
"sender_user_id",
|
||||
} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
|
||||
}
|
||||
}
|
||||
if doc["jq_root_path"] != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
|
||||
}
|
||||
schema, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema = %#v, want object", doc["schema"])
|
||||
}
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
if _, ok := props["content"].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSchemaDefaultsToUser(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{personal.EventSingleChat})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != personal.EventSingleChat {
|
||||
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "schema",
|
||||
cmd: newEventSchemaCommand(),
|
||||
args: []string{personal.EventFromUser},
|
||||
},
|
||||
{
|
||||
name: "consume",
|
||||
cmd: newEventConsumeCommand(),
|
||||
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
cmd: newEventStatusCommand(),
|
||||
args: []string{"--event", personal.EventFromUser},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.cmd.SilenceUsage = true
|
||||
tc.cmd.SilenceErrors = true
|
||||
tc.cmd.SetArgs(tc.args)
|
||||
err := tc.cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want not publicly available", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--format", "table"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "event schema only supports json output") {
|
||||
t.Fatalf("Execute() error = %v, want json-only format validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventAsBotRejected(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPersonalOutputHidesSchemaIDs(t *testing.T, out string) {
|
||||
t.Helper()
|
||||
for _, leaked := range []string{"SCHEMA_IDS", "schema_ids", "im_msg_23", "im_msg_29"} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Fatalf("output leaked %q: %s", leaked, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func TestRenderPersonalStatusTextShowsConsumersWithoutSubscriptions(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
SourceID: "source-1",
|
||||
}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateRunning,
|
||||
HolderPID: 100,
|
||||
},
|
||||
Live: &transport.StatusResp{
|
||||
Consumers: []transport.StatusConsumer{
|
||||
{
|
||||
PID: 12345,
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
SubscribeID: "subId-1",
|
||||
Filter: "content",
|
||||
Received: 3,
|
||||
Dropped: 1,
|
||||
},
|
||||
{
|
||||
PID: 12346,
|
||||
Received: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"Subscriptions: none",
|
||||
"Consumers:",
|
||||
"PID",
|
||||
"EVENT_KEYS",
|
||||
"SUBSCRIBE_ID",
|
||||
"RECEIVED",
|
||||
"DROPPED",
|
||||
"12345",
|
||||
"user_im_message_receive_o2o",
|
||||
"subId-1",
|
||||
"content",
|
||||
"3",
|
||||
"1",
|
||||
"(catch-all)",
|
||||
"-",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("status output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPersonalStatusTextConsumersUnavailableWhenRPCFails(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateRunning,
|
||||
HolderPID: 100,
|
||||
},
|
||||
})
|
||||
if got := out.String(); !strings.Contains(got, "Consumers: unavailable (status RPC failed)") {
|
||||
t.Fatalf("status output = %q, want unavailable consumers", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPersonalStatusTextConsumersNoneWhenBusNotRunning(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateNotRunning,
|
||||
},
|
||||
})
|
||||
if got := out.String(); !strings.Contains(got, "Consumers: none") {
|
||||
t.Fatalf("status output = %q, want no consumers", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
event := newEventCommand()
|
||||
unregistered := &cobra.Command{Use: "unregistered", Run: func(*cobra.Command, []string) {}}
|
||||
root.AddCommand(event, unregistered)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if event.Hidden {
|
||||
t.Fatal("built-in event command was hidden by the direct-runtime visibility filter")
|
||||
}
|
||||
if !unregistered.Hidden {
|
||||
t.Fatal("control command outside the built-in/direct-runtime sets remained visible")
|
||||
}
|
||||
|
||||
var leaves []string
|
||||
for _, command := range event.Commands() {
|
||||
if command.Hidden || !command.Runnable() {
|
||||
continue
|
||||
}
|
||||
leaves = append(leaves, command.Name())
|
||||
}
|
||||
sort.Strings(leaves)
|
||||
want := []string{"consume", "list", "schema", "status", "stop"}
|
||||
if len(leaves) != len(want) {
|
||||
t.Fatalf("public event leaves = %v, want %v", leaves, want)
|
||||
}
|
||||
for index := range want {
|
||||
if leaves[index] != want[index] {
|
||||
t.Fatalf("public event leaves = %v, want %v", leaves, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
builtIn := newEventCommand()
|
||||
root.AddCommand(builtIn)
|
||||
|
||||
pluginEvent := &cobra.Command{Use: "event", Run: func(*cobra.Command, []string) {}}
|
||||
addPluginCommandsSafe(root, []*cobra.Command{pluginEvent})
|
||||
|
||||
var eventCommands []*cobra.Command
|
||||
for _, command := range root.Commands() {
|
||||
if command.Name() == "event" {
|
||||
eventCommands = append(eventCommands, command)
|
||||
}
|
||||
}
|
||||
if len(eventCommands) != 1 || eventCommands[0] != builtIn {
|
||||
t.Fatalf("event command after plugin registration = %p (%d matches), want built-in %p", firstEventCommand(eventCommands), len(eventCommands), builtIn)
|
||||
}
|
||||
if pluginEvent.Parent() != nil {
|
||||
t.Fatal("conflicting plugin event command was attached to the root")
|
||||
}
|
||||
}
|
||||
|
||||
func firstEventCommand(commands []*cobra.Command) *cobra.Command {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
return commands[0]
|
||||
}
|
||||
@@ -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"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
// shape: --max-events / --duration are the lifecycle control.
|
||||
func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
|
||||
if shouldWatchStdinEOF(1, 0) {
|
||||
t.Error("--max-events set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(0, 5*time.Second) {
|
||||
t.Error("--duration set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(3, 2*time.Second) {
|
||||
t.Error("both bounds set should not arm stdin watcher")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the detached _bus child must receive --profile so it resolves
|
||||
// credentials for the same organization as the parent. Missing it made a
|
||||
// non-default `--profile` consume fail with "bus child reported startup
|
||||
// failure on ready pipe" (no bus.log).
|
||||
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
args := personalBusSpawnArgs(personal.Identity{
|
||||
CorpID: "dinga626d60c1128d449",
|
||||
SourceID: "open",
|
||||
}, "", "")
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
|
||||
}
|
||||
|
||||
// No CorpID → no --profile appended (avoid an empty flag value).
|
||||
bare := personalBusSpawnArgs(personal.Identity{SourceID: "open"}, "", "")
|
||||
for _, a := range bare {
|
||||
if a == "--profile" {
|
||||
t.Errorf("must not append --profile when CorpID is empty; got %v", bare)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventStopRequiresTypedConfirmationBeforeMutation(t *testing.T) {
|
||||
root, _ := newEventStopSafetyRoot()
|
||||
root.SetArgs([]string{"event", "stop", "sub-1"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("event stop without --yes or --dry-run unexpectedly succeeded")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("event stop confirmation error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
if appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("event stop confirmation reason = %q, want confirmation_required", appErr.Reason)
|
||||
}
|
||||
for _, recoveryFlag := range []string{"--dry-run", "--yes"} {
|
||||
if !strings.Contains(err.Error(), recoveryFlag) {
|
||||
t.Fatalf("event stop confirmation error %q does not explain %s", err, recoveryFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopDryRunPrecedesConfirmationAndReturnsPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantAll bool
|
||||
wantSubscribeID string
|
||||
}{
|
||||
{name: "single subscription", args: []string{"event", "stop", "sub-1", "--dry-run"}, wantSubscribeID: "sub-1"},
|
||||
{name: "all subscriptions", args: []string{"--dry-run", "event", "stop", "--all"}, wantAll: true},
|
||||
{name: "dry run wins over yes", args: []string{"event", "stop", "sub-2", "--yes", "--dry-run"}, wantSubscribeID: "sub-2"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root, stdout := newEventStopSafetyRoot()
|
||||
root.SetArgs(test.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("event stop dry-run error = %v", err)
|
||||
}
|
||||
var preview map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &preview); err != nil {
|
||||
t.Fatalf("decode event stop dry-run preview: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if preview["dry_run"] != true || preview["action"] != "event.stop" || preview["identity"] != "user" {
|
||||
t.Fatalf("event stop dry-run preview = %#v", preview)
|
||||
}
|
||||
if got, _ := preview["all"].(bool); got != test.wantAll {
|
||||
t.Fatalf("event stop dry-run all = %v, want %v", got, test.wantAll)
|
||||
}
|
||||
if got, _ := preview["subscribe_id"].(string); got != test.wantSubscribeID {
|
||||
t.Fatalf("event stop dry-run subscribe_id = %q, want %q", got, test.wantSubscribeID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "missing target", args: []string{"event", "stop", "--dry-run"}, want: "subscribe_id is required unless --all is set"},
|
||||
{name: "conflicting targets", args: []string{"event", "stop", "sub-1", "--all", "--dry-run"}, want: "subscribe_id and --all are mutually exclusive"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root, _ := newEventStopSafetyRoot()
|
||||
root.SetArgs(test.args)
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("event stop dry-run validation error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newEventStopSafetyRoot() (*cobra.Command, *bytes.Buffer) {
|
||||
stdout := &bytes.Buffer{}
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
root.PersistentFlags().Bool("yes", false, "confirm execution")
|
||||
event := &cobra.Command{Use: "event"}
|
||||
event.AddCommand(newEventStopCommand())
|
||||
root.AddCommand(event)
|
||||
return root, stdout
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestEventStopHelpDescribesPersonalSubscription(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"stop [subscribe_id]",
|
||||
"取消个人事件订阅并停止本地消费",
|
||||
"取消个人事件订阅并停止本地消费,清理对应本地消费状态",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, stale := range []string{"优雅停止 bus 守护进程", strings.Join([]string{"--as", "app"}, " "), "应用事件"} {
|
||||
if strings.Contains(got, stale) {
|
||||
t.Fatalf("help still contains stale public app wording %q:\n%s", stale, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopRequiresSubscribeIDOrAll(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
|
||||
t.Fatalf("Execute() error = %v, want subscribe_id requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopSubscribeIDAndAllAreMutuallyExclusive(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"subId-1", "--all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "subscribe_id and --all are mutually exclusive") {
|
||||
t.Fatalf("Execute() error = %v, want mutual exclusion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopAsAppRejectsSubscribeID(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", "subId-1"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalStopTargets(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-b"}); err != nil {
|
||||
t.Fatalf("UpsertRunState() error = %v", err)
|
||||
}
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-a"}); err != nil {
|
||||
t.Fatalf("UpsertRunState() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := personalStopTargets(workDir, "sub-explicit", false)
|
||||
if err != nil {
|
||||
t.Fatalf("personalStopTargets(explicit) error = %v", err)
|
||||
}
|
||||
if want := []string{"sub-explicit"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("explicit targets = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got, err = personalStopTargets(workDir, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("personalStopTargets(all) error = %v", err)
|
||||
}
|
||||
if want := []string{"sub-a", "sub-b"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("all targets = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
if _, err := personalStopTargets(workDir, "", false); err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
|
||||
t.Fatalf("personalStopTargets(no target) error = %v, want required error", err)
|
||||
}
|
||||
if _, err := personalStopTargets(workDir, "sub-explicit", true); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("personalStopTargets(explicit+all) error = %v, want mutual exclusion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPersonalStopResult(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printPersonalStopResult(&out, []string{"sub-1"}, true, "personal bus stopped")
|
||||
if got := out.String(); got != "cancelled personal subscription sub-1; personal bus stopped\n" {
|
||||
t.Fatalf("single output = %q", got)
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
printPersonalStopResult(&out, []string{"sub-1", "sub-2"}, false, "personal bus still running")
|
||||
if got := out.String(); got != "cancelled 2 personal subscription(s); personal bus still running\n" {
|
||||
t.Fatalf("multi output = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type GlobalFlags struct {
|
||||
JQ string
|
||||
Mock bool
|
||||
Output string
|
||||
Profile string
|
||||
Timeout int
|
||||
Token string
|
||||
Verbose bool
|
||||
@@ -46,6 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
_ = cmd.PersistentFlags().MarkHidden("output")
|
||||
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
|
||||
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
|
||||
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
|
||||
_ = cmd.PersistentFlags().MarkHidden("token")
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 3, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("doc", "文档管理", nil, map[string]any{
|
||||
"search_docs": map[string]any{
|
||||
"cliName": "search",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
discoveryServerEntry("chat", "聊天管理", map[string]any{
|
||||
"message": map[string]any{"description": "消息管理"},
|
||||
}, map[string]any{
|
||||
"list_messages": map[string]any{
|
||||
"cliName": "list",
|
||||
"group": "message",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
discoveryServerEntry("minutes", "听记管理", map[string]any{
|
||||
"list": map[string]any{"description": "列表"},
|
||||
}, map[string]any{
|
||||
"list_minutes_mine": map[string]any{
|
||||
"cliName": "mine",
|
||||
"group": "list",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, path := range []string{
|
||||
"chat message list-topic-replies",
|
||||
"minutes list all",
|
||||
} {
|
||||
if cmd := lookupCommand(root, path); cmd != nil {
|
||||
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicLeafHelpDoesNotUsePatchedExamplesOrFlagText(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"alias": "prompt",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"aiapp", "create", "--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(aiapp create --help) error = %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if strings.Contains(got, "创建一个天气查询应用") {
|
||||
t.Fatalf("leaf help still contains patched example:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "创建 AI 应用的 prompt(必填)") {
|
||||
t.Fatalf("leaf help still contains patched flag usage:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--prompt string") {
|
||||
t.Fatalf("leaf help missing dynamic prompt flag:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
discoveryServerEntry("aitable", "多维表管理", nil, map[string]any{
|
||||
"list_bases": map[string]any{
|
||||
"cliName": "list",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(--help) error = %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{"Discovered MCP Services:", "aiapp", "AI应用管理", "aitable", "多维表管理"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"alias": "prompt",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"aiapp", "--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(aiapp --help) error = %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "Usage:") || !strings.Contains(got, "Available Commands:") || !strings.Contains(got, "Flags:") {
|
||||
t.Fatalf("subcommand help should still use cobra default sections:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "Discovered MCP Services:") {
|
||||
t.Fatalf("subcommand help should not render root-only MCP summary:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
|
||||
t.Fatal("upgrade command should be registered on root, but was not found")
|
||||
}
|
||||
}
|
||||
|
||||
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
|
||||
cliMeta := map[string]any{
|
||||
"id": command,
|
||||
"command": command,
|
||||
"description": description,
|
||||
"toolOverrides": toolOverrides,
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
cliMeta["groups"] = groups
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": command,
|
||||
"description": description,
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/" + command,
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active",
|
||||
"isLatest": true,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": cliMeta,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func lookupCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
if root == nil || path == "" {
|
||||
return root
|
||||
}
|
||||
|
||||
cmd := root
|
||||
for _, part := range strings.Fields(path) {
|
||||
found := false
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == part {
|
||||
cmd = child
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP
|
||||
// server's tools/list LIVE (by source) and projects each tool into a
|
||||
// cli.HelperToolSchema (name, description, inputSchema properties/required). It
|
||||
// is injected into the schema command so the cli package can render
|
||||
// `dws schema dev.*` from real server schema without importing app/transport.
|
||||
//
|
||||
// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc"
|
||||
// backs `dws dev doc search` (endpoint resolved dynamically, see
|
||||
// helperSourceEndpoint). Results are memoized per source per process so
|
||||
// repeated `dws schema dev.*` hit the network at most once per source. A failed
|
||||
// fetch is not cached, allowing a later retry within the same process.
|
||||
func newHelperToolFetcher() cli.HelperToolFetcher {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cached = map[string]map[string]cli.HelperToolSchema{}
|
||||
)
|
||||
return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) {
|
||||
mu.Lock()
|
||||
if got, ok := cached[source]; ok {
|
||||
mu.Unlock()
|
||||
return got, nil
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
endpoint, err := helperSourceEndpoint(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemas, err := fetchHelperToolSchemas(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
cached[source] = schemas
|
||||
mu.Unlock()
|
||||
return schemas, nil
|
||||
}
|
||||
}
|
||||
|
||||
// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev
|
||||
// app) is pinned in source (devappMCPEndpoint, derived from the active gateway
|
||||
// base — production by default, pre when ~/.dws/mcp_url points at pre); other
|
||||
// sources (e.g. devdoc) are resolved the same way the runner resolves a product
|
||||
// endpoint — env override → discovery → edition StaticServers/SupplementServers.
|
||||
func helperSourceEndpoint(source string) (string, error) {
|
||||
switch source {
|
||||
case "", "op-app", "devapp":
|
||||
return devappMCPEndpoint(), nil
|
||||
default:
|
||||
if endpoint, ok := directRuntimeEndpoint(source, ""); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchHelperToolSchemas performs the live tools/list call against endpoint and
|
||||
// converts the descriptors. Auth and identity headers are resolved the same way
|
||||
// the runner does for direct-runtime invocations.
|
||||
func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) {
|
||||
token := resolveRuntimeAuthToken(ctx, "")
|
||||
headers := resolveIdentityHeaders()
|
||||
client := transport.NewClient(nil).WithAuth(token, headers)
|
||||
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]cli.HelperToolSchema, len(result.Tools))
|
||||
for _, td := range result.Tools {
|
||||
out[td.Name] = cli.HelperToolSchema{
|
||||
Name: td.Name,
|
||||
Description: td.Description,
|
||||
Properties: inputSchemaProperties(td.InputSchema),
|
||||
Required: inputSchemaRequired(td.InputSchema),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inputSchemaProperties pulls the "properties" object out of a deserialized
|
||||
// MCP inputSchema map. Returns an empty (non-nil) map when absent.
|
||||
func inputSchemaProperties(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
if props == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// inputSchemaRequired pulls the "required" string list out of a deserialized
|
||||
// MCP inputSchema map.
|
||||
func inputSchemaRequired(schema map[string]any) []string {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+18
-554
@@ -14,170 +14,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"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/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
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)
|
||||
}
|
||||
|
||||
return buildEnvelopeCommandsSafe(ctx, runner)
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
// loadDynamicCommandsFn is a test seam for buildEnvelopeCommandsSafe so a
|
||||
// panic in the cache-driven build can be simulated without crafting a
|
||||
// poisoned on-disk cache.
|
||||
var loadDynamicCommandsFn = loadDynamicCommands
|
||||
func injectStaticServers() {
|
||||
hooks := edition.Get()
|
||||
var servers []edition.ServerInfo
|
||||
|
||||
// buildEnvelopeCommandsSafe builds the public command set from the discovery
|
||||
// envelope, self-healing a poisoned cache when the dynamic build panics and
|
||||
// degrading to the hardcoded helper commands only if that also fails.
|
||||
//
|
||||
// Why this guard exists: the dynamic command tree is constructed from cached
|
||||
// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
|
||||
// a duplicate pflag registration fed by a poisoned cache, as seen before
|
||||
// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
|
||||
// every invocation — including `dws cache refresh`, the very command that
|
||||
// repairs the cache.
|
||||
//
|
||||
// Recovery is two-staged. First the partition's discovery cache is moved
|
||||
// aside (kept on disk for inspection) and the build retried against a fresh
|
||||
// fetch — so any path that delivers a fixed binary (`dws upgrade`, reinstall)
|
||||
// escapes the lock-out with zero manual cache surgery. Only when the rebuild
|
||||
// panics again (e.g. the remote envelope itself is still poisoned, or the
|
||||
// machine is offline with no usable cache) does the CLI degrade to utility
|
||||
// and helper commands with a `dws cache refresh` hint.
|
||||
func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
cmds, panicked := tryBuildEnvelopeCommands(ctx, runner)
|
||||
if panicked == nil {
|
||||
return cmds
|
||||
if fn := hooks.StaticServers; fn != nil {
|
||||
servers = append(servers, fn()...)
|
||||
}
|
||||
slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked", "panic", panicked)
|
||||
|
||||
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
|
||||
if qErr != nil {
|
||||
slog.Error("buildEnvelopeCommandsSafe: failed to quarantine discovery cache", "error", qErr)
|
||||
}
|
||||
if quarantined != "" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building product commands from the local discovery cache failed: %v\n"+
|
||||
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
|
||||
panicked, quarantined)
|
||||
cmds, panicked = tryBuildEnvelopeCommands(ctx, runner)
|
||||
if panicked == nil {
|
||||
fmt.Fprintln(os.Stderr, "Product commands rebuilt successfully.")
|
||||
return cmds
|
||||
}
|
||||
slog.Error("buildEnvelopeCommandsSafe: rebuild after cache quarantine panicked again, degrading to built-in commands", "panic", panicked)
|
||||
if fn := hooks.SupplementServers; fn != nil {
|
||||
servers = append(servers, fn()...)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building product commands from the local discovery cache failed: %v\n"+
|
||||
"Product commands are temporarily unavailable; utility commands still work.\n"+
|
||||
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
|
||||
return mergeTopLevelCommands(helpers.NewPublicCommands(runner))
|
||||
}
|
||||
|
||||
// tryBuildEnvelopeCommands runs one attempt of the envelope-driven build,
|
||||
// converting a panic into a return value so the caller can decide between
|
||||
// self-heal and degradation.
|
||||
func tryBuildEnvelopeCommands(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command, panicked any) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
cmds = nil
|
||||
panicked = r
|
||||
}
|
||||
}()
|
||||
|
||||
dynamicCmds := loadDynamicCommandsFn(ctx, runner)
|
||||
helperCmds := helpers.NewPublicCommands(runner)
|
||||
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
// Post-merge product hooks: tasks the envelope cannot express on its
|
||||
// own (e.g. dual-role group+leaf semantics for deprecated aliases).
|
||||
// Keep each hook narrowly scoped to one product so the open-source
|
||||
// command surface remains predictable from the envelope alone.
|
||||
helpers.AttachReportLegacyInboxAlias(merged, runner)
|
||||
helpers.AttachReportListReadableEnrichment(merged, runner)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
// 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)
|
||||
if len(servers) == 0 {
|
||||
return
|
||||
}
|
||||
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))
|
||||
descriptors := make([]mcptypes.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
descriptors = append(descriptors, market.ServerDescriptor{
|
||||
descriptors = append(descriptors, mcptypes.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
@@ -187,412 +62,6 @@ func injectStaticServers(servers []edition.ServerInfo) {
|
||||
SetDynamicServers(descriptors)
|
||||
}
|
||||
|
||||
// loadDynamicCommands loads the server registry and generates CLI commands
|
||||
// dynamically from CLIOverlay metadata. It consults the disk cache first.
|
||||
// Within the short revalidation window it uses the cached registry directly;
|
||||
// after that it revalidates against the live market registry. Once the hard
|
||||
// RegistryTTL expires, a successful live registry fetch triggers a full detail
|
||||
// refresh for every server so command metadata cannot stay pinned to an
|
||||
// arbitrarily old snapshot. On network failure with a stale cache, it
|
||||
// gracefully degrades to the cached data so the CLI remains functional
|
||||
// offline.
|
||||
//
|
||||
// Tests may override discoveryBaseURLOverride to redirect to a local server;
|
||||
// in that case the registry cache is always bypassed.
|
||||
// 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 := editionPartition()
|
||||
|
||||
// Bypass the registry cache when a fixture override is active.
|
||||
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
|
||||
// data from their local mock server without interference from a
|
||||
// stale on-disk cache written by a previous production run.
|
||||
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 {
|
||||
servers = snapshot.Servers
|
||||
// Only trigger async revalidation in production (no URL override).
|
||||
// Tests set discoveryBaseURLOverride and control cache expiry directly,
|
||||
// so background revalidation would interfere with test expectations.
|
||||
if discoveryBaseURLOverride == "" && (freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, snapshot.SavedAt)) {
|
||||
go asyncRevalidateRegistry(ctx, store, partition)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if discoveryTraceEnabled() {
|
||||
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
|
||||
} else {
|
||||
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", DiscoveryBaseURL())
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
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)
|
||||
RecordTiming(ctx, "build_commands", time.Since(buildStart))
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
|
||||
// no network calls. Returns whatever is available (fresh or stale).
|
||||
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
|
||||
result := make(map[string][]market.DetailTool)
|
||||
if store == nil {
|
||||
return result
|
||||
}
|
||||
partition := editionPartition()
|
||||
for _, server := range servers {
|
||||
if server.DetailLocator.MCPID <= 0 {
|
||||
continue
|
||||
}
|
||||
serverID := strings.TrimSpace(server.CLI.ID)
|
||||
if serverID == "" {
|
||||
continue
|
||||
}
|
||||
snap, _, err := store.LoadDetail(partition, serverID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Tools []market.DetailTool `json:"tools"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
|
||||
result[serverID] = payload.Tools
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// fetchDetailsByServerID fetches MCP Detail API tool metadata for each server
|
||||
// with a known mcpId. Returns a map from CLI server ID → []DetailTool.
|
||||
// Results are read from / written to the disk cache (DetailTTL=7d).
|
||||
// All network fetches run concurrently; best-effort (errors silently skip).
|
||||
func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers []market.ServerDescriptor, store *cache.Store, forceRefresh bool) map[string][]market.DetailTool {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
partition := editionPartition()
|
||||
now := time.Now().UTC()
|
||||
if store != nil && store.Now != nil {
|
||||
now = store.Now().UTC()
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
id string
|
||||
tools []market.DetailTool
|
||||
}
|
||||
|
||||
results := make(chan entry, len(servers))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, server := range servers {
|
||||
mcpID := server.DetailLocator.MCPID
|
||||
if mcpID <= 0 {
|
||||
continue
|
||||
}
|
||||
serverID := strings.TrimSpace(server.CLI.ID)
|
||||
if serverID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(srv market.ServerDescriptor, sID string, mID int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("fetchDetailsByServerID: goroutine panicked", "server", sID, "panic", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Cache hit check. Fresh entries within the short revalidation window
|
||||
// are returned immediately. Older entries still serve as fallback if
|
||||
// the live market detail request fails.
|
||||
var cachedTools []market.DetailTool
|
||||
haveCachedTools := false
|
||||
if store != nil {
|
||||
if snap, freshness, err := store.LoadDetail(partition, sID); err == nil {
|
||||
var payload struct {
|
||||
Tools []market.DetailTool `json:"tools"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
|
||||
cachedTools = payload.Tools
|
||||
haveCachedTools = true
|
||||
}
|
||||
if !forceRefresh && freshness == cache.FreshnessFresh && haveCachedTools && !cache.ShouldRevalidate(now, snap.SavedAt) {
|
||||
slog.Debug("fetchDetailsByServerID: using cached detail", "id", sID)
|
||||
results <- entry{id: sID, tools: cachedTools}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Network fetch with per-server 5s timeout.
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var detail market.DetailResponse
|
||||
var fetchErr error
|
||||
detailURL := strings.TrimSpace(srv.DetailLocator.DetailURL)
|
||||
if detailURL != "" {
|
||||
detail, fetchErr = client.FetchDetailByURL(fetchCtx, detailURL)
|
||||
} else {
|
||||
detail, fetchErr = client.FetchDetail(fetchCtx, mID)
|
||||
}
|
||||
if fetchErr != nil {
|
||||
slog.Debug("fetchDetailsByServerID: skipping server", "id", sID, "mcpId", mID, "error", fetchErr)
|
||||
if haveCachedTools {
|
||||
results <- entry{id: sID, tools: cachedTools}
|
||||
}
|
||||
return
|
||||
}
|
||||
if !detail.Success || len(detail.Result.Tools) == 0 {
|
||||
if haveCachedTools {
|
||||
results <- entry{id: sID, tools: cachedTools}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Persist to cache.
|
||||
if store != nil {
|
||||
if payload, marshalErr := json.Marshal(map[string]any{"tools": detail.Result.Tools}); marshalErr == nil {
|
||||
if saveErr := store.SaveDetail(partition, sID, cache.DetailSnapshot{
|
||||
MCPID: mID,
|
||||
Payload: payload,
|
||||
}); saveErr != nil {
|
||||
slog.Debug("fetchDetailsByServerID: failed to save detail cache", "id", sID, "error", saveErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("fetchDetailsByServerID: got tool details", "id", sID, "tools", len(detail.Result.Tools))
|
||||
results <- entry{id: sID, tools: detail.Result.Tools}
|
||||
}(server, serverID, mcpID)
|
||||
}
|
||||
|
||||
// Close channel after all goroutines finish.
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
result := make(map[string][]market.DetailTool)
|
||||
for e := range results {
|
||||
result[e.id] = e.tools
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// discoveryBaseURLOverride allows tests to redirect discovery to a local server.
|
||||
// Must be empty in production; only set during test execution.
|
||||
var discoveryBaseURLOverride string
|
||||
|
||||
// SetDiscoveryBaseURL sets the base URL used for dynamic server discovery.
|
||||
// Intended for test use only.
|
||||
func SetDiscoveryBaseURL(url string) {
|
||||
discoveryBaseURLOverride = url
|
||||
}
|
||||
|
||||
// DiscoveryBaseURL returns the effective base URL for discovery —
|
||||
// discoveryBaseURLOverride if set, otherwise DefaultMarketBaseURL.
|
||||
func DiscoveryBaseURL() string {
|
||||
if discoveryBaseURLOverride != "" {
|
||||
return discoveryBaseURLOverride
|
||||
}
|
||||
return config.GetMCPBaseURL()
|
||||
}
|
||||
|
||||
// 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: 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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func registryDiscoveryBaseURL() string {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
return editionURL
|
||||
}
|
||||
return DiscoveryBaseURL()
|
||||
}
|
||||
|
||||
// asyncRevalidateRegistry refreshes the registry cache in the background.
|
||||
// Uses a short timeout derived from the parent context and silently ignores
|
||||
// errors — the next CLI invocation will pick up the refreshed cache or retry.
|
||||
func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partition string) {
|
||||
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
|
||||
if err != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
}
|
||||
servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
@@ -623,8 +92,3 @@ 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).
|
||||
|
||||
@@ -1,743 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"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/spf13/cobra"
|
||||
)
|
||||
|
||||
// marketListResponse builds a minimal valid FetchServers JSON response.
|
||||
// The server has a ToolOverride so BuildDynamicCommands emits a command.
|
||||
func marketListResponse(cliID string) map[string]any {
|
||||
return map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": "Test Server",
|
||||
"description": "desc",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/test/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": cliID,
|
||||
"command": cliID,
|
||||
"toolOverrides": map[string]any{
|
||||
"test_tool": map[string]any{
|
||||
"cliName": "test",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type testCLIServerSpec struct {
|
||||
id string
|
||||
command string
|
||||
tool string
|
||||
cliName string
|
||||
}
|
||||
|
||||
func marketListResponseForSpecs(specs ...testCLIServerSpec) map[string]any {
|
||||
servers := make([]any, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
servers = append(servers, map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": spec.command,
|
||||
"description": spec.command + " desc",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/" + spec.command + "/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": spec.id,
|
||||
"command": spec.command,
|
||||
"toolOverrides": map[string]any{
|
||||
spec.tool: map[string]any{
|
||||
"cliName": spec.cliName,
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"metadata": map[string]any{"count": len(servers), "nextCursor": ""},
|
||||
"servers": servers,
|
||||
}
|
||||
}
|
||||
|
||||
// minimalCLIServer returns a ServerDescriptor with ToolOverrides so
|
||||
// BuildDynamicCommands will emit at least one cobra command.
|
||||
func minimalCLIServer(id, endpoint string) market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: id + "-key",
|
||||
DisplayName: id,
|
||||
Endpoint: endpoint,
|
||||
Source: "market",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: id,
|
||||
Command: id,
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"test_tool": {CLIName: "test"},
|
||||
},
|
||||
},
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork verifies that when a
|
||||
// fresh registry cache exists, no network request is made.
|
||||
//
|
||||
// This test uses an isolated DWS_CACHE_DIR + discoveryBaseURLOverride so that:
|
||||
// - useCache=true (DWS_CATALOG_FIXTURE is "")
|
||||
// - The test server records any incoming request; it should NOT be hit when cache is fresh.
|
||||
func TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
requestCount := new(atomic.Int32)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("test-fresh"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Isolated cache dir with a FRESH snapshot.
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC(), // fresh
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
// Point discovery to the test server. Since cache is fresh and
|
||||
// useCache=true (CATALOG_FIXTURE is ""), the network should not be needed.
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
if got := requestCount.Load(); got != 0 {
|
||||
t.Errorf("network request count = %d, want 0 (fresh cache should be used)", got)
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Errorf("loadDynamicCommands() returned 0 commands, want >0 from fresh cache")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsUsesStaleCacheOnStartup verifies that when the
|
||||
// registry cache is stale, startup still returns commands from the cache
|
||||
// instead of blocking on a synchronous market refresh.
|
||||
func TestLoadDynamicCommandsUsesStaleCacheOnStartup(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
requestCount := new(atomic.Int32)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Isolated cache dir with a STALE snapshot.
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // older than RegistryTTL=24h
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("stale", "https://mcp.dingtalk.com/stale/v1")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("loadDynamicCommands() = 0 commands, want >0 from stale cache")
|
||||
}
|
||||
if got := requestCount.Load(); got != 0 {
|
||||
t.Errorf("startup network request count = %d, want 0 (stale cache should not block startup)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsCacheUpdatedAfterFetch verifies the cache is persisted
|
||||
// after a successful network fetch (useCache=true, isolated cache dir).
|
||||
func TestLoadDynamicCommandsCacheUpdatedAfterFetch(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("fresh-server"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL) // stale/empty cache → network
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
_ = loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
snapshot, freshness, err := store.LoadRegistry("default/default")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRegistry() after fetch error = %v", err)
|
||||
}
|
||||
if freshness != cache.FreshnessFresh {
|
||||
t.Errorf("cache freshness = %s, want fresh", freshness)
|
||||
}
|
||||
if len(snapshot.Servers) == 0 {
|
||||
t.Errorf("cache servers = 0, want >0 after network fetch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError verifies that
|
||||
// when the market API is unavailable but a stale cache exists, the CLI
|
||||
// still generates commands from the stale data (offline degradation).
|
||||
func TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // stale
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("degraded", "https://mcp.dingtalk.com/degraded/v1")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Errorf("loadDynamicCommands() = 0 commands, want >0 (stale fallback on network error)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsRefreshesRegistryCacheInBackgroundAfterAgedStart(t *testing.T) {
|
||||
// Skip: async revalidation is disabled when discoveryBaseURLOverride is set.
|
||||
// This test requires background refresh which only runs in production mode.
|
||||
t.Skip("async revalidation disabled in test mode")
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
var phase atomic.Int32
|
||||
phase.Store(1)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
payload := marketListResponseForSpecs(testCLIServerSpec{
|
||||
id: "doc",
|
||||
command: "doc",
|
||||
tool: "create_document",
|
||||
cliName: "create-document",
|
||||
})
|
||||
if phase.Load() == 2 {
|
||||
payload = marketListResponseForSpecs(
|
||||
testCLIServerSpec{
|
||||
id: "doc",
|
||||
command: "doc",
|
||||
tool: "archive_document",
|
||||
cliName: "archive-document",
|
||||
},
|
||||
testCLIServerSpec{
|
||||
id: "drive",
|
||||
command: "drive",
|
||||
tool: "list_files",
|
||||
cliName: "list-files",
|
||||
},
|
||||
)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
|
||||
snapshot, _, err := store.LoadRegistry("default/default")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRegistry() error = %v", err)
|
||||
}
|
||||
snapshot.SavedAt = time.Now().UTC().Add(-2 * time.Hour)
|
||||
if err := store.SaveRegistry("default/default", snapshot); err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
phase.Store(2)
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
refreshed, _, err := store.LoadRegistry("default/default")
|
||||
if err == nil && len(refreshed.Servers) == 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"archive-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
var phase atomic.Int32
|
||||
docDetailCalls := new(atomic.Int32)
|
||||
driveDetailCalls := new(atomic.Int32)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
|
||||
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
|
||||
},
|
||||
}
|
||||
if phase.Load() == 1 {
|
||||
payload["servers"] = []any{
|
||||
registryServerEnvelope("doc", "doc", "2026-03-25T10:00:00Z", 1001, "archive_document", "archive-document"),
|
||||
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
|
||||
}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
case r.URL.Path == "/mcp/market/detail":
|
||||
switch r.URL.Query().Get("mcpId") {
|
||||
case "1001":
|
||||
docDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1001, "archive_document", "Archive Document", "archive desc"))
|
||||
case "1002":
|
||||
driveDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
|
||||
default:
|
||||
http.Error(w, "unknown mcpId", http.StatusNotFound)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
|
||||
phase.Store(1)
|
||||
docDetailCalls.Store(0)
|
||||
driveDetailCalls.Store(0)
|
||||
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-2*time.Hour))
|
||||
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after aged startup = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after aged startup = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryTTLExpires(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
docDetailCalls := new(atomic.Int32)
|
||||
driveDetailCalls := new(atomic.Int32)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis/bamboo":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
|
||||
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
|
||||
},
|
||||
})
|
||||
case r.URL.Path == "/mcp/market/detail":
|
||||
switch r.URL.Query().Get("mcpId") {
|
||||
case "1001":
|
||||
docDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1001, "create_document", "Create Document", "create desc"))
|
||||
case "1002":
|
||||
driveDetailCalls.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
|
||||
default:
|
||||
http.Error(w, "unknown mcpId", http.StatusNotFound)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after startup = %d, want 0", got)
|
||||
}
|
||||
|
||||
docDetailCalls.Store(0)
|
||||
driveDetailCalls.Store(0)
|
||||
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-25*time.Hour))
|
||||
|
||||
cmds = loadDynamicCommands(context.Background(), nil)
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
|
||||
|
||||
if got := docDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("doc detail calls after registry TTL expiry = %d, want 0", got)
|
||||
}
|
||||
if got := driveDetailCalls.Load(); got != 0 {
|
||||
t.Fatalf("drive detail calls after registry TTL expiry = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDynamicCommandsUsesStaleCacheWithoutBlockingRegistryRefresh(t *testing.T) {
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponseForSpecs(testCLIServerSpec{
|
||||
id: "doc",
|
||||
command: "doc",
|
||||
tool: "archive_document",
|
||||
cliName: "archive-document",
|
||||
}))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-25 * time.Hour),
|
||||
Servers: []market.ServerDescriptor{
|
||||
{
|
||||
Key: "doc-key",
|
||||
DisplayName: "doc",
|
||||
Endpoint: "https://mcp.dingtalk.com/doc/v1",
|
||||
Source: "market",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_document": {CLIName: "create-document"},
|
||||
},
|
||||
},
|
||||
HasCLIMeta: true,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
start := time.Now()
|
||||
cmds := loadDynamicCommands(context.Background(), nil)
|
||||
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
|
||||
t.Fatalf("loadDynamicCommands() took %v, want stale cache startup under 200ms", elapsed)
|
||||
}
|
||||
|
||||
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
|
||||
}
|
||||
|
||||
// TestFetchDetailsByServerIDRunsConcurrently verifies that detail fetches are
|
||||
// concurrent, not serial. Uses MCPID path to avoid the localhost SSRF guard.
|
||||
func TestFetchDetailsByServerIDRunsConcurrently(t *testing.T) {
|
||||
const numServers = 4
|
||||
const delay = 50 * time.Millisecond
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(delay)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"mcpId": 1, "name": "test", "description": "test",
|
||||
"tools": []any{
|
||||
map[string]any{"toolName": "test_tool", "toolTitle": "Test Tool", "toolDesc": "desc"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
servers := make([]market.ServerDescriptor, numServers)
|
||||
for i := range servers {
|
||||
servers[i] = market.ServerDescriptor{
|
||||
DetailLocator: market.DetailLocator{MCPID: i + 1},
|
||||
CLI: market.CLIOverlay{ID: "test-server-" + string(rune('a'+i))},
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, cache.NewStore(t.TempDir()), false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
serialBound := time.Duration(numServers) * delay
|
||||
if elapsed >= serialBound {
|
||||
t.Errorf("elapsed %v >= serial bound %v: requests appear serial, want concurrent", elapsed, serialBound)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Errorf("fetchDetailsByServerID() = empty map, want results")
|
||||
}
|
||||
}
|
||||
|
||||
func assertDynamicCommandChildren(t *testing.T, cmds []*cobra.Command, name string, want []string) {
|
||||
t.Helper()
|
||||
|
||||
for _, cmd := range cmds {
|
||||
if cmd.Name() != name {
|
||||
continue
|
||||
}
|
||||
got := make([]string, 0)
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
got = append(got, child.Name())
|
||||
}
|
||||
sort.Strings(got)
|
||||
|
||||
sortedWant := append([]string(nil), want...)
|
||||
sort.Strings(sortedWant)
|
||||
if len(got) != len(sortedWant) {
|
||||
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
|
||||
}
|
||||
for idx := range got {
|
||||
if got[idx] != sortedWant[idx] {
|
||||
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatalf("command %q not found", name)
|
||||
}
|
||||
|
||||
func registryServerEnvelope(id, command, updatedAt string, mcpID int, toolName, cliName string) map[string]any {
|
||||
return map[string]any{
|
||||
"server": map[string]any{
|
||||
"name": command,
|
||||
"description": command + " desc",
|
||||
"remotes": []any{
|
||||
map[string]any{
|
||||
"type": "streamable-http",
|
||||
"url": "https://mcp.dingtalk.com/" + command + "/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"_meta": map[string]any{
|
||||
"com.dingtalk.mcp.registry/metadata": map[string]any{
|
||||
"status": "active",
|
||||
"isLatest": true,
|
||||
"updatedAt": updatedAt,
|
||||
"publishedAt": updatedAt,
|
||||
"mcpId": mcpID,
|
||||
},
|
||||
"com.dingtalk.mcp.registry/cli": map[string]any{
|
||||
"id": id,
|
||||
"command": command,
|
||||
"toolOverrides": map[string]any{
|
||||
toolName: map[string]any{
|
||||
"cliName": cliName,
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func detailResponse(mcpID int, toolName, title, desc string) map[string]any {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"mcpId": mcpID,
|
||||
"name": title,
|
||||
"description": desc,
|
||||
"tools": []any{
|
||||
map[string]any{
|
||||
"toolName": toolName,
|
||||
"toolTitle": title,
|
||||
"toolDesc": desc,
|
||||
"toolRequest": `{"type":"object"}`,
|
||||
"toolResponse": `{"type":"object"}`,
|
||||
"actionVersion": "v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ageCacheSnapshotsOnDisk(t *testing.T, root string, savedAt time.Time) {
|
||||
t.Helper()
|
||||
|
||||
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := payload["saved_at"]; !ok {
|
||||
return nil
|
||||
}
|
||||
payload["saved_at"] = savedAt.Format(time.RFC3339Nano)
|
||||
|
||||
rewritten, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, rewritten, 0o644)
|
||||
})
|
||||
if walkErr != nil {
|
||||
t.Fatalf("ageCacheSnapshotsOnDisk() error = %v", walkErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDetailsByServerIDUsesCacheOnHit verifies that a fresh detail cache
|
||||
// entry prevents any network request.
|
||||
func TestFetchDetailsByServerIDUsesCacheOnHit(t *testing.T) {
|
||||
requestCount := new(atomic.Int32)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"tools": []any{}}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := cache.NewStore(t.TempDir())
|
||||
cachedTools := []market.DetailTool{{ToolName: "cached_tool", ToolTitle: "Cached", ToolDesc: "from cache"}}
|
||||
cachedJSON, _ := json.Marshal(map[string]any{"tools": cachedTools})
|
||||
err := store.SaveDetail("default/default", "test-server", cache.DetailSnapshot{
|
||||
SavedAt: time.Now().UTC(),
|
||||
MCPID: 42,
|
||||
Payload: cachedJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDetail() error = %v", err)
|
||||
}
|
||||
|
||||
servers := []market.ServerDescriptor{
|
||||
{DetailLocator: market.DetailLocator{MCPID: 42}, CLI: market.CLIOverlay{ID: "test-server"}, HasCLIMeta: true},
|
||||
}
|
||||
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, store, false)
|
||||
|
||||
if got := requestCount.Load(); got != 0 {
|
||||
t.Errorf("network request count = %d, want 0 (fresh detail cache should be used)", got)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Errorf("fetchDetailsByServerID() returned empty map, want cached tools")
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// captureStderr redirects os.Stderr for the duration of fn and returns what
|
||||
// was written to it.
|
||||
func captureStderr(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
pipeR, pipeW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() error = %v", err)
|
||||
}
|
||||
origStderr := os.Stderr
|
||||
os.Stderr = pipeW
|
||||
defer func() { os.Stderr = origStderr }()
|
||||
|
||||
fn()
|
||||
|
||||
_ = pipeW.Close()
|
||||
os.Stderr = origStderr
|
||||
captured, _ := io.ReadAll(pipeR)
|
||||
return string(captured)
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
|
||||
// hatch for a poisoned discovery cache: when the dynamic command build
|
||||
// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
|
||||
// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
|
||||
// the panic. With no on-disk cache to quarantine there is nothing to
|
||||
// self-heal from, so it degrades to the hardcoded helper commands and
|
||||
// prints a stderr hint pointing at `dws cache refresh`.
|
||||
func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
|
||||
// path: when the build panics AND a discovery cache exists on disk, the
|
||||
// partition is quarantined (moved aside, kept for inspection) and the build
|
||||
// retried once. The retry succeeding means the user gets the full dynamic
|
||||
// command tree with zero manual cache surgery.
|
||||
func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
partition := editionPartition()
|
||||
if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
|
||||
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
||||
if len(quarantines) != 1 {
|
||||
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
|
||||
t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
|
||||
}
|
||||
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
||||
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
||||
}
|
||||
if strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
|
||||
// safety net: if the rebuild after quarantine panics again (remote envelope
|
||||
// still poisoned, or offline), the CLI degrades to helper commands and keeps
|
||||
// the `dws cache refresh` hint.
|
||||
func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, tmp)
|
||||
|
||||
store := cache.NewStore(tmp)
|
||||
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
||||
t.Fatalf("SaveTools() error = %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
calls++
|
||||
panic("chat_permission_grant flag redefined: params")
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
var cmds []*cobra.Command
|
||||
captured := captureStderr(t, func() {
|
||||
cmds = newLegacyPublicCommands(context.Background(), nil)
|
||||
})
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
|
||||
}
|
||||
if !strings.Contains(captured, "dws cache refresh") {
|
||||
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
|
||||
// transparent on the happy path: commands returned by the dynamic build
|
||||
// still reach the caller unchanged.
|
||||
func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
|
||||
orig := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
|
||||
return []*cobra.Command{{Use: "dynamic-probe"}}
|
||||
}
|
||||
t.Cleanup(func() { loadDynamicCommandsFn = orig })
|
||||
|
||||
cmds := newLegacyPublicCommands(context.Background(), nil)
|
||||
|
||||
found := false
|
||||
for _, c := range cmds {
|
||||
if c.Name() == "dynamic-probe" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_a"),
|
||||
authLogoutTestToken("corp_b"),
|
||||
)
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_b")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
Params: map[string]any{"limit": 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
|
||||
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
|
||||
}
|
||||
|
||||
content := result.Response["content"].(map[string]any)
|
||||
if content["multiProfile"] != true {
|
||||
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
|
||||
}
|
||||
if content["success"] != true {
|
||||
t.Fatalf("success = %#v, want true", content["success"])
|
||||
}
|
||||
profiles := content["profiles"].([]any)
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(profiles))
|
||||
}
|
||||
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
|
||||
entry := profiles[i].(map[string]any)
|
||||
if entry["corpId"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
|
||||
}
|
||||
if entry["ok"] != true {
|
||||
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
|
||||
}
|
||||
resultPayload := entry["result"].(map[string]any)
|
||||
if resultPayload["runtimeProfile"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if !multi {
|
||||
t.Fatal("multi = false, want true")
|
||||
}
|
||||
if len(selections) != 2 {
|
||||
t.Fatalf("selections len = %d, want 2", len(selections))
|
||||
}
|
||||
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
|
||||
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
|
||||
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a" {
|
||||
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == "corp_comma" {
|
||||
cfg.Profiles[i].Name = "alpha,beta"
|
||||
}
|
||||
}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if multi {
|
||||
t.Fatalf("multi = true, want false; selections=%#v", selections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
|
||||
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
|
||||
if err == nil {
|
||||
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("error = %q, want empty profile selector", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type multiProfileFallbackRunner struct{}
|
||||
|
||||
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"runtimeProfile": authpkg.RuntimeProfile(),
|
||||
"tool": invocation.Tool,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeMultiSkillSrc creates a fake multi skill source tree with the given
|
||||
// subdir names, each containing a minimal SKILL.md.
|
||||
func writeMultiSkillSrc(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
src := t.TempDir()
|
||||
for _, n := range names {
|
||||
dir := filepath.Join(src, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,10 @@ func TestPrintPatAuthError_HumanReadable(t *testing.T) {
|
||||
|
||||
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
// PAT browser policy is user-configurable. Isolate the config directory so
|
||||
// this serializer test exercises the built-in CLI-owned default instead of
|
||||
// inheriting the developer's ~/.dws/pat_policy.json.
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
var buf strings.Builder
|
||||
scopeErr := &PatScopeError{
|
||||
Identity: "user",
|
||||
@@ -590,6 +594,33 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string {
|
||||
type patData struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
payload := struct {
|
||||
Code string `json:"code"`
|
||||
Data patData `json:"data"`
|
||||
}{
|
||||
Code: "AGENT_CODE_NOT_EXISTS",
|
||||
Data: patData{
|
||||
Desc: "test auth",
|
||||
FlowID: flowID,
|
||||
AuthorizationURL: authURL,
|
||||
ClientID: clientID,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func patTestAuthorizationURL(server *httptest.Server) string {
|
||||
return server.URL + "/pat"
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
|
||||
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
|
||||
@@ -664,10 +695,13 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
|
||||
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var retried atomic.Bool
|
||||
var retryHadKey atomic.Bool
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
|
||||
@@ -691,7 +725,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
@@ -721,7 +755,7 @@ func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
@@ -1196,7 +1230,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "table"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
authURL := patTestAuthorizationURL(server)
|
||||
raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL)
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -1216,7 +1251,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
if !strings.Contains(buf.String(), "需要 PAT 授权") {
|
||||
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
|
||||
if !strings.Contains(buf.String(), "授权链接: "+authURL) {
|
||||
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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,98 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"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/mcptypes"
|
||||
)
|
||||
|
||||
func isolatePluginRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
dynamicMu.Lock()
|
||||
previousEndpoints := dynamicEndpoints
|
||||
previousProducts := dynamicProducts
|
||||
previousAliases := dynamicAliases
|
||||
previousToolEndpoints := dynamicToolEndpoints
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
|
||||
stdioMu.Lock()
|
||||
previousStdio := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
StopAllStdioClients()
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = previousEndpoints
|
||||
dynamicProducts = previousProducts
|
||||
dynamicAliases = previousAliases
|
||||
dynamicToolEndpoints = previousToolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
stdioMu.Lock()
|
||||
stdioClients = previousStdio
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
registerPluginHTTPServer(mcptypes.ServerDescriptor{
|
||||
Key: "offline-http",
|
||||
Endpoint: server.URL,
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: "offline-http",
|
||||
Command: "offline-http",
|
||||
},
|
||||
})
|
||||
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("plugin endpoint calls during registration = %d, want 0", got)
|
||||
}
|
||||
if endpoint, ok := directRuntimeEndpoint("offline-http", ""); !ok || endpoint != server.URL {
|
||||
t.Fatalf("registered endpoint = (%q, %v), want (%q, true)", endpoint, ok, server.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
marker := t.TempDir() + "/started"
|
||||
client := transport.NewStdioClient("/bin/sh", []string{
|
||||
"-c", fmt.Sprintf("printf started > %q", marker),
|
||||
}, nil)
|
||||
p := &plugin.Plugin{
|
||||
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
|
||||
Root: t.TempDir(),
|
||||
}
|
||||
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
|
||||
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("stdio process started during registration: stat error = %v", err)
|
||||
}
|
||||
if descriptor.Endpoint != StdioEndpoint("lazy-stdio", "local") {
|
||||
t.Fatalf("descriptor endpoint = %q", descriptor.Endpoint)
|
||||
}
|
||||
if _, ok := LookupStdioClient("lazy-stdio/local"); !ok {
|
||||
t.Fatal("stdio client was not registered for lazy execution")
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,8 @@ import (
|
||||
"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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
)
|
||||
|
||||
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
|
||||
@@ -38,9 +32,9 @@ import (
|
||||
//
|
||||
// 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 {
|
||||
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
|
||||
serverID := sc.Key
|
||||
overlay := market.CLIOverlay{
|
||||
overlay := mcptypes.CLIOverlay{
|
||||
ID: serverID,
|
||||
Command: serverID,
|
||||
}
|
||||
@@ -77,55 +71,12 @@ func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.C
|
||||
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) {
|
||||
// registerStdioServerFromManifest registers an endpoint descriptor and an
|
||||
// unstarted client from versioned plugin metadata. Tool discovery is not part
|
||||
// of command-tree construction; execution starts and initializes the client.
|
||||
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
return nil, market.ServerDescriptor{}, false
|
||||
}
|
||||
|
||||
descriptor := market.ServerDescriptor{
|
||||
descriptor := mcptypes.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
@@ -138,69 +89,8 @@ func registerStdioServerFromOverlay(
|
||||
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",
|
||||
slog.Debug("plugin: stdio server registered from manifest",
|
||||
"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
|
||||
"toolOverrides", len(overlay.ToolOverrides))
|
||||
return descriptor
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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,82 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "root profile before command",
|
||||
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "profile after leaf command",
|
||||
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
|
||||
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
|
||||
},
|
||||
{
|
||||
name: "equals form",
|
||||
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "three profiles",
|
||||
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "already quoted by shell remains unchanged",
|
||||
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "single profile remains unchanged",
|
||||
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "trailing comma before next flag remains validation input",
|
||||
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, _ := normalizeProfileFlagArgs(tc.args)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
|
||||
if got != "corpA,corpB" {
|
||||
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
|
||||
restore := normalizeProcessProfileArgs()
|
||||
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
restore()
|
||||
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newProfileCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "组织 profile 管理",
|
||||
Long: `管理本机已登录的钉钉组织 profile。
|
||||
|
||||
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
|
||||
profile switch/use 才会持久修改默认组织上下文。`,
|
||||
Example: ` dws profile list
|
||||
dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "列出已登录组织 profile",
|
||||
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
|
||||
Example: ` dws profile list
|
||||
dws profile list --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
|
||||
}
|
||||
writeProfileListTable(cmd.OutOrStdout(), cfg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newProfileUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use [name|corpId|-]",
|
||||
Short: "切换当前组织 profile(兼容 profile switch)",
|
||||
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
|
||||
Example: ` dws profile use <corpId>
|
||||
dws profile use --name "钉钉"
|
||||
dws profile use -`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileSwitchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "switch [name|corpId|-]",
|
||||
Short: "切换当前组织 profile",
|
||||
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
|
||||
|
||||
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
|
||||
需要只影响单次业务命令时,请使用全局 --profile。`,
|
||||
Example: ` dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch --corpId <corpId>
|
||||
dws profile switch --name "钉钉"
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
|
||||
_ = cmd.Flags().MarkHidden("corp-id")
|
||||
_ = cmd.Flags().MarkHidden("corpid")
|
||||
_ = cmd.Flags().MarkHidden("corp")
|
||||
}
|
||||
|
||||
var (
|
||||
profileSwitchSelector = selectProfileSwitchProfile
|
||||
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
||||
)
|
||||
|
||||
const (
|
||||
profileSwitchVisibleOptions = 5
|
||||
profileSwitchCellPadding = 1
|
||||
profileSwitchOrgWidth = 34
|
||||
profileSwitchStatusWidth = 10
|
||||
)
|
||||
|
||||
var profileSwitchRenderer = newProfileSwitchRenderer()
|
||||
|
||||
func newProfileSwitchRenderer() *lipgloss.Renderer {
|
||||
renderer := lipgloss.NewRenderer(io.Discard)
|
||||
renderer.SetColorProfile(termenv.TrueColor)
|
||||
renderer.SetHasDarkBackground(true)
|
||||
return renderer
|
||||
}
|
||||
|
||||
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
selector, err := profileSwitchSelectorFromCommand(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI := false
|
||||
if selector == "" {
|
||||
selector, err = profileSwitchSelector(cmd, configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI = true
|
||||
}
|
||||
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
|
||||
}
|
||||
|
||||
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
|
||||
selectors := make([]string, 0, 2)
|
||||
if len(args) > 0 {
|
||||
selectors = append(selectors, strings.TrimSpace(args[0]))
|
||||
}
|
||||
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
|
||||
value, changed := changedStringFlag(cmd, name)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
|
||||
}
|
||||
selectors = append(selectors, value)
|
||||
}
|
||||
if len(selectors) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
selector := selectors[0]
|
||||
for _, candidate := range selectors[1:] {
|
||||
if candidate != selector {
|
||||
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
|
||||
}
|
||||
}
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
|
||||
if cmd == nil || cmd.Flags() == nil {
|
||||
return "", false
|
||||
}
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil || !flag.Changed {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(flag.Value.String()), true
|
||||
}
|
||||
|
||||
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
|
||||
var (
|
||||
profile *authpkg.Profile
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(selector) == "-" {
|
||||
profile, err = authpkg.UsePreviousProfile(configDir)
|
||||
} else {
|
||||
profile, err = authpkg.SetCurrentProfile(configDir, selector)
|
||||
}
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
|
||||
cfg, loadErr := authpkg.LoadProfiles(configDir)
|
||||
if loadErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
|
||||
}
|
||||
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
|
||||
if !profileSwitchInteractiveTerminal() {
|
||||
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
|
||||
}
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
|
||||
}
|
||||
choice := strings.TrimSpace(cfg.CurrentProfile)
|
||||
if choice == "" {
|
||||
choice = strings.TrimSpace(cfg.PrimaryProfile)
|
||||
}
|
||||
if choice == "" {
|
||||
choice = cfg.Profiles[0].CorpID
|
||||
}
|
||||
return runProfileSwitchTUI(cmd, cfg, choice)
|
||||
}
|
||||
|
||||
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
|
||||
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithInput(cmd.InOrStdin()),
|
||||
tea.WithOutput(cmd.ErrOrStderr()),
|
||||
tea.WithContext(cmd.Context()),
|
||||
)
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, tea.ErrInterrupted) {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
|
||||
}
|
||||
final, ok := finalModel.(profileSwitchTUIModel)
|
||||
if !ok || final.aborted || !final.submitted {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return final.selectedCorpID(), nil
|
||||
}
|
||||
|
||||
type profileSwitchTUIModel struct {
|
||||
cfg *authpkg.ProfilesConfig
|
||||
profiles []authpkg.Profile
|
||||
selected int
|
||||
offset int
|
||||
submitted bool
|
||||
aborted bool
|
||||
}
|
||||
|
||||
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
|
||||
model := profileSwitchTUIModel{cfg: cfg}
|
||||
if cfg != nil {
|
||||
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
|
||||
}
|
||||
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
|
||||
if model.selected < 0 {
|
||||
model.selected = 0
|
||||
}
|
||||
model.ensureSelectedVisible()
|
||||
return model
|
||||
}
|
||||
|
||||
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
|
||||
sorted := append([]authpkg.Profile(nil), profiles...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
left, leftOK := profileSwitchSortTime(sorted[i])
|
||||
right, rightOK := profileSwitchSortTime(sorted[j])
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return false
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
|
||||
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
|
||||
if t, ok := parseProfileSwitchTime(raw); ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseProfileSwitchTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
m.aborted = true
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selected < len(m.profiles)-1 {
|
||||
m.selected++
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "enter":
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) View() string {
|
||||
var b strings.Builder
|
||||
title := profileSwitchTitleStyle().Render("选择要切换的组织")
|
||||
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(hint)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.tableView())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) tableView() string {
|
||||
rows := []string{
|
||||
profileSwitchBorder("┌", "┬", "┐"),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchBorder("├", "┼", "┤"),
|
||||
}
|
||||
for i := 0; i < profileSwitchVisibleOptions; i++ {
|
||||
idx := m.offset + i
|
||||
if idx >= 0 && idx < len(m.profiles) {
|
||||
rows = append(rows, m.profileRow(idx))
|
||||
continue
|
||||
}
|
||||
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
|
||||
}
|
||||
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) profileRow(idx int) string {
|
||||
profile := m.profiles[idx]
|
||||
org, status := profileSwitchProfileCells(profile, m.cfg)
|
||||
style := profileSwitchNormalRowStyle()
|
||||
if idx == m.selected {
|
||||
org = "› " + org
|
||||
style = profileSwitchSelectedRowStyle()
|
||||
} else {
|
||||
org = " " + org
|
||||
}
|
||||
return profileSwitchStyledTableLine(org, status, style)
|
||||
}
|
||||
|
||||
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
|
||||
if len(m.profiles) == 0 {
|
||||
m.selected = 0
|
||||
m.offset = 0
|
||||
return
|
||||
}
|
||||
if m.selected < 0 {
|
||||
m.selected = 0
|
||||
}
|
||||
if m.selected >= len(m.profiles) {
|
||||
m.selected = len(m.profiles) - 1
|
||||
}
|
||||
if m.selected < m.offset {
|
||||
m.offset = m.selected
|
||||
}
|
||||
if m.selected >= m.offset+profileSwitchVisibleOptions {
|
||||
m.offset = m.selected - profileSwitchVisibleOptions + 1
|
||||
}
|
||||
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if m.offset > maxOffset {
|
||||
m.offset = maxOffset
|
||||
}
|
||||
if m.offset < 0 {
|
||||
m.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) selectedCorpID() string {
|
||||
if m.selected < 0 || m.selected >= len(m.profiles) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.profiles[m.selected].CorpID)
|
||||
}
|
||||
|
||||
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.CorpID) == corpID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
org, status := profileSwitchProfileCells(p, cfg)
|
||||
if status == "" {
|
||||
return org
|
||||
}
|
||||
return strings.Join([]string{org, status}, " | ")
|
||||
}
|
||||
|
||||
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
|
||||
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
|
||||
}
|
||||
|
||||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
if cfg != nil && p.CorpID == cfg.CurrentProfile {
|
||||
return "当前组织"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func profileSwitchBorder(left, sep, right string) string {
|
||||
segments := []string{
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
|
||||
}
|
||||
|
||||
func profileSwitchTableLine(org, status string) string {
|
||||
cells := []string{
|
||||
profileSwitchTableCell(org, profileSwitchOrgWidth),
|
||||
profileSwitchTableCell(status, profileSwitchStatusWidth),
|
||||
}
|
||||
return "│" + strings.Join(cells, "│") + "│"
|
||||
}
|
||||
|
||||
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
|
||||
cells := []string{
|
||||
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
|
||||
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
|
||||
}
|
||||
|
||||
func profileSwitchTableSeparator() string {
|
||||
return profileSwitchBorderStyle().Render("│")
|
||||
}
|
||||
|
||||
func profileSwitchTableCell(value string, width int) string {
|
||||
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
|
||||
padding := strings.Repeat(" ", profileSwitchCellPadding)
|
||||
return padding + padProfileDisplayCell(clipped, width) + padding
|
||||
}
|
||||
|
||||
func padProfileDisplayCell(value string, width int) string {
|
||||
padding := width - lipgloss.Width(value)
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
return value + strings.Repeat(" ", padding)
|
||||
}
|
||||
|
||||
func profileSwitchCellWidth(contentWidth int) int {
|
||||
return contentWidth + profileSwitchCellPadding*2
|
||||
}
|
||||
|
||||
func profileSwitchSelectedRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchNormalRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
|
||||
}
|
||||
|
||||
func profileSwitchHeaderStyle() lipgloss.Style {
|
||||
return profileSwitchMutedStyle().Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchBorderStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
|
||||
}
|
||||
|
||||
func profileSwitchTitleStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
|
||||
}
|
||||
|
||||
type profileListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
||||
CurrentProfile string `json:"currentProfile,omitempty"`
|
||||
PreviousProfile string `json:"previousProfile,omitempty"`
|
||||
Profiles []profileView `json:"profiles"`
|
||||
}
|
||||
|
||||
type profileUseResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Profile profileView `json:"profile"`
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
}
|
||||
|
||||
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileListResponse{
|
||||
Success: true,
|
||||
PrimaryProfile: cfg.PrimaryProfile,
|
||||
CurrentProfile: cfg.CurrentProfile,
|
||||
PreviousProfile: cfg.PreviousProfile,
|
||||
Profiles: profileViews(cfg),
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileUseResponse{Success: true}
|
||||
if profile != nil {
|
||||
primaryProfile := ""
|
||||
currentProfile := ""
|
||||
if cfg != nil {
|
||||
primaryProfile = cfg.PrimaryProfile
|
||||
currentProfile = cfg.CurrentProfile
|
||||
}
|
||||
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
fmt.Fprintln(w, "未找到已登录 profile")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||
for _, p := range cfg.Profiles {
|
||||
current := ""
|
||||
if p.CorpID == cfg.CurrentProfile {
|
||||
current = "*"
|
||||
}
|
||||
primary := ""
|
||||
if p.CorpID == cfg.PrimaryProfile {
|
||||
primary = "*"
|
||||
}
|
||||
user := p.UserName
|
||||
if user == "" {
|
||||
user = p.UserID
|
||||
}
|
||||
status := p.Status
|
||||
if status == "" {
|
||||
status = authpkg.ProfileStatusActive
|
||||
}
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%-3s %-3s %-28s %-34s %-10s %s\n",
|
||||
current,
|
||||
primary,
|
||||
clipProfileCell(profileOrgName(p), 28),
|
||||
clipProfileCell(p.CorpID, 34),
|
||||
status,
|
||||
user,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func profileUseMessage(profile *authpkg.Profile) string {
|
||||
if profile == nil {
|
||||
return "[OK] 当前 profile 已切换"
|
||||
}
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
orgName := strings.TrimSpace(profile.CorpName)
|
||||
if orgName == "" {
|
||||
orgName = profileOrgName(*profile)
|
||||
}
|
||||
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
|
||||
}
|
||||
|
||||
func profileOrgName(p authpkg.Profile) string {
|
||||
if v := strings.TrimSpace(p.CorpName); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(p.Name); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
views := make([]profileView, 0, len(cfg.Profiles))
|
||||
for _, p := range cfg.Profiles {
|
||||
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
|
||||
return profileView{
|
||||
CorpID: p.CorpID,
|
||||
CorpName: profileOrgName(p),
|
||||
UserID: p.UserID,
|
||||
UserName: p.UserName,
|
||||
ClientID: p.ClientID,
|
||||
Status: p.Status,
|
||||
AuthorizedDomains: p.AuthorizedDomains,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
RefreshExpAt: p.RefreshExpAt,
|
||||
LastLoginAt: p.LastLoginAt,
|
||||
LastUsedAt: p.LastUsedAt,
|
||||
IsPrimary: p.CorpID == primaryProfile,
|
||||
IsCurrent: p.CorpID == currentProfile,
|
||||
}
|
||||
}
|
||||
|
||||
func clipProfileCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return string(runes[:limit-3]) + "..."
|
||||
}
|
||||
|
||||
func clipProfileDisplayCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(value) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if lipgloss.Width(b.String())+rw > limit {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
target := limit - 3
|
||||
var b strings.Builder
|
||||
width := 0
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if width+rw > target {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
width += rw
|
||||
}
|
||||
return b.String() + "..."
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
|
||||
profile := &authpkg.Profile{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B Org",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
|
||||
t.Fatalf("writeProfileUseJSON() error = %v", err)
|
||||
}
|
||||
var resp profileUseResponse
|
||||
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
|
||||
}
|
||||
if resp.Profile.CorpName != "B Org" {
|
||||
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
|
||||
}
|
||||
if !resp.Profile.IsCurrent {
|
||||
t.Fatalf("isCurrent = false, want true")
|
||||
}
|
||||
if resp.Profile.IsPrimary {
|
||||
t.Fatalf("isPrimary = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var resp profileListResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatal("success = false, want true")
|
||||
}
|
||||
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
|
||||
}
|
||||
if len(resp.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
|
||||
}
|
||||
for _, p := range resp.Profiles {
|
||||
if p.CorpName == "" {
|
||||
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
|
||||
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err = authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_secondary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
|
||||
t.Fatalf("error = %v, want conflicting selector validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
called := false
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
called = true
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("profile switch without args did not invoke TUI selector")
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_primary",
|
||||
CurrentProfile: "corp_secondary",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
CorpID: "corp_primary",
|
||||
CorpName: "第一组织",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
CorpID: "corp_secondary",
|
||||
CorpName: "第二组织",
|
||||
UserName: "bob",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
},
|
||||
}
|
||||
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
|
||||
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
|
||||
for _, label := range []string{primary, current} {
|
||||
if strings.Contains(label, "\n") {
|
||||
t.Fatalf("profile switch label contains newline: %q", label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(primary, "第一组织") {
|
||||
t.Fatalf("primary option missing organization name: %q", primary)
|
||||
}
|
||||
if !strings.Contains(current, "当前组织") {
|
||||
t.Fatalf("current option missing current marker: %q", current)
|
||||
}
|
||||
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
|
||||
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
|
||||
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(2)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
view := model.tableView()
|
||||
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
|
||||
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
|
||||
}
|
||||
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
|
||||
if strings.Contains(view, unwanted) {
|
||||
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
|
||||
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
if got := strings.Count(view, profile.CorpID); got != 0 {
|
||||
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "old",
|
||||
CurrentProfile: "old",
|
||||
Profiles: []authpkg.Profile{
|
||||
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
|
||||
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
|
||||
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
|
||||
},
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, "old")
|
||||
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
|
||||
wantOrder := []string{"new", "fallback", "old"}
|
||||
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
|
||||
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != "old" {
|
||||
t.Fatalf("selectedCorpID = %q, want old", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(7)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
for step := 0; step < 6; step++ {
|
||||
view := model.tableView()
|
||||
if got := strings.Count(view, "›"); got != 1 {
|
||||
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
name := profileOrgName(profile)
|
||||
if got := strings.Count(view, name); got > 1 {
|
||||
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
|
||||
}
|
||||
}
|
||||
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = next.(profileSwitchTUIModel)
|
||||
}
|
||||
if model.selected != 6 || model.offset != 2 {
|
||||
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
|
||||
rows := []string{
|
||||
profileSwitchTableLine("组织名", "本地状态"),
|
||||
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
|
||||
profileSwitchTableLine(" ACME", ""),
|
||||
profileSwitchTableLine("", ""),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
|
||||
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
|
||||
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
|
||||
}
|
||||
wantWidth := lipgloss.Width(rows[0])
|
||||
for i, row := range rows {
|
||||
if got := lipgloss.Width(row); got != wantWidth {
|
||||
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
|
||||
}
|
||||
if got := strings.Count(row, "│"); got != 3 {
|
||||
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
|
||||
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: corpID,
|
||||
CurrentProfile: corpID,
|
||||
}
|
||||
label := profileSwitchOptionLabel(authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: "钉钉",
|
||||
}, cfg)
|
||||
for _, want := range []string{"钉钉", "当前组织"} {
|
||||
if !strings.Contains(label, want) {
|
||||
t.Fatalf("profile switch label missing %q in %q", want, label)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
|
||||
if strings.Contains(label, unwanted) {
|
||||
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_00",
|
||||
CurrentProfile: "corp_00",
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
corpID := fmt.Sprintf("corp_%02d", i)
|
||||
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: fmt.Sprintf("组织%02d", i),
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "switch"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
|
||||
t.Fatalf("error = %v, want auth switch unknown command", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "use"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
|
||||
oldInteractive := profileSwitchInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
profileSwitchInteractiveTerminal = oldInteractive
|
||||
})
|
||||
profileSwitchInteractiveTerminal = func() bool { return false }
|
||||
|
||||
_, err := selectProfileSwitchProfile(nil, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
|
||||
t.Fatalf("error = %v, want profile selector hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "corp_a",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B 组织",
|
||||
UserID: "bob-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writeProfileListTable(&buf, cfg)
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"ORG_NAME",
|
||||
"钉钉(中国)信息技术有限公司",
|
||||
"B 组织",
|
||||
"corp_a",
|
||||
"corp_b",
|
||||
} {
|
||||
if !bytes.Contains(buf.Bytes(), []byte(want)) {
|
||||
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
|
||||
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
|
||||
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
|
||||
got := profileUseMessage(&authpkg.Profile{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "ding8196",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
})
|
||||
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
|
||||
if !bytes.Contains([]byte(got), []byte(want)) {
|
||||
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
|
||||
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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)")
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
)
|
||||
|
||||
func TestRecoveryPlanReadsLastSnapshotAndPrintsJSON(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_test",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
CLIErrorCode: "RESOURCE_NOT_FOUND",
|
||||
RawError: "resource_not_found",
|
||||
Fingerprint: "fp-1",
|
||||
},
|
||||
Replay: recovery.Replay{
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
ToolArgs: map[string]any{"instanceId": "ins_1"},
|
||||
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"recovery", "plan", "--last", "-f", "json"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(recovery plan) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"event_id": "evt_test"`) {
|
||||
t.Fatalf("output missing event id:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"category": "resource"`) {
|
||||
t.Fatalf("output missing resource category:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryExecuteReadsLastSnapshotAndPrintsJSON(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_exec",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
CLIErrorCode: "RESOURCE_NOT_FOUND",
|
||||
RawError: "resource_not_found",
|
||||
Fingerprint: "fp-2",
|
||||
},
|
||||
Replay: recovery.Replay{
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
ToolArgs: map[string]any{"instanceId": "ins_1"},
|
||||
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"recovery", "execute", "--last", "-f", "json"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(recovery execute) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"event_id": "evt_exec"`) {
|
||||
t.Fatalf("output missing event id:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"status": "needs_agent_action"`) {
|
||||
t.Fatalf("output missing bundle status:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryFinalizeRequiresEventIDAndOutcome(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"recovery", "finalize"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute(recovery finalize) error = nil, want validation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--event-id") {
|
||||
t.Fatalf("error = %v, want event-id requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryPlanRejectsLastAndEventIDTogether(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_conflict",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationRead,
|
||||
CLIErrorCode: "RESOURCE_NOT_FOUND",
|
||||
RawError: "resource_not_found",
|
||||
Fingerprint: "fp-conflict",
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"recovery", "plan", "--last", "--event-id", "evt_conflict"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute(recovery plan) error = nil, want conflict validation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--last") || !strings.Contains(err.Error(), "--event-id") {
|
||||
t.Fatalf("error = %v, want mutually exclusive flags", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryFinalizeAcceptsLegacyExecutionFile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
writeRecoverySnapshot(t, configDir, recovery.LastError{
|
||||
EventID: "evt_legacy_finalize",
|
||||
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Context: recovery.RecoveryContext{
|
||||
CommandPath: []string{"approval", "instance", "get"},
|
||||
ServerID: "approval",
|
||||
ToolName: "get_approval_instance",
|
||||
OperationKind: recovery.OperationUnknown,
|
||||
RawError: "unexpected upstream failure",
|
||||
Fingerprint: "fp-legacy-finalize",
|
||||
},
|
||||
})
|
||||
|
||||
executionPath := filepath.Join(configDir, "legacy_execution.json")
|
||||
if err := os.WriteFile(executionPath, []byte(`{"action":"verify_resource_exists","attempts":2,"result":"failed","error":"resource still missing"}`), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(legacy execution) error = %v", err)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{
|
||||
"recovery", "finalize",
|
||||
"--event-id", "evt_legacy_finalize",
|
||||
"--outcome", "failed",
|
||||
"--execution-file", executionPath,
|
||||
"-f", "json",
|
||||
})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(recovery finalize) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"execution_recorded": true`) {
|
||||
t.Fatalf("output missing execution_recorded flag:\n%s", out.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "recovery_events.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(recovery_events.jsonl) error = %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
lastLine := lines[len(lines)-1]
|
||||
if !strings.Contains(lastLine, `"phase":"finalized"`) {
|
||||
t.Fatalf("expected finalized event, got %s", lastLine)
|
||||
}
|
||||
if !strings.Contains(lastLine, `"legacy_execution_file"`) {
|
||||
t.Fatalf("expected legacy execution attempts to be normalized, got %s", lastLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
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
|
||||
}
|
||||
switch req["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{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "baseId is required",
|
||||
},
|
||||
},
|
||||
"isError": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false))
|
||||
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
|
||||
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe(stdout) error = %v", err)
|
||||
}
|
||||
stderrR, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe(stderr) error = %v", err)
|
||||
}
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
}()
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
|
||||
exitCode := Execute()
|
||||
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
stdoutData, _ := io.ReadAll(stdoutR)
|
||||
stderrData, _ := io.ReadAll(stderrR)
|
||||
|
||||
if exitCode == 0 {
|
||||
t.Fatalf("Execute() exitCode = 0, want failure\nstdout:\n%s\nstderr:\n%s", stdoutData, stderrData)
|
||||
}
|
||||
if !strings.Contains(string(stderrData), "RECOVERY_EVENT_ID=evt_") {
|
||||
t.Fatalf("stderr missing recovery event id:\n%s", stderrData)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "last_error.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(last_error.json) error = %v", err)
|
||||
}
|
||||
var last recovery.LastError
|
||||
if err := json.Unmarshal(data, &last); err != nil {
|
||||
t.Fatalf("json.Unmarshal(last_error) error = %v", err)
|
||||
}
|
||||
if last.EventID == "" || last.Context.ToolName != "search_documents" {
|
||||
t.Fatalf("unexpected recovery snapshot %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRecoverySnapshot(t *testing.T, configDir string, last recovery.LastError) {
|
||||
t.Helper()
|
||||
|
||||
recoveryDir := filepath.Join(configDir, "recovery")
|
||||
if err := os.MkdirAll(recoveryDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(recovery) error = %v", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(last, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("json.MarshalIndent() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(recoveryDir, "last_error.json"), append(data, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(last_error.json) error = %v", err)
|
||||
}
|
||||
}
|
||||
+158
-942
File diff suppressed because it is too large
Load Diff
@@ -1,254 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/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) {
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
|
||||
var skippedRuntimeCalls atomic.Int32
|
||||
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/discovery/apis/bamboo":
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 2},
|
||||
Servers: []market.ServerEnvelope{
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Active Service",
|
||||
Remotes: []market.RegistryRemote{
|
||||
{Type: "streamable-http", URL: srv.URL + "/mcp/active"},
|
||||
},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "active", Command: "active"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: market.RegistryServer{
|
||||
Name: "Skipped Service",
|
||||
Remotes: []market.RegistryRemote{
|
||||
{Type: "streamable-http", URL: srv.URL + "/mcp/skipped"},
|
||||
},
|
||||
},
|
||||
Meta: market.EnvelopeMeta{
|
||||
Registry: market.RegistryMetadata{Status: "active"},
|
||||
CLI: market.CLIOverlay{ID: "legacy", Command: "legacy", Skip: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/mcp/active":
|
||||
http.Error(w, "active runtime unavailable", http.StatusInternalServerError)
|
||||
case "/mcp/skipped":
|
||||
skippedRuntimeCalls.Add(1)
|
||||
http.Error(w, "skipped runtime should not be called", http.StatusInternalServerError)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := cache.NewStore(cacheDir)
|
||||
const partition = "default/default"
|
||||
activeKey := market.ServerKey(srv.URL + "/mcp/active")
|
||||
skippedKey := market.ServerKey(srv.URL + "/mcp/skipped")
|
||||
|
||||
saveCachedRuntimeAndDetail(t, store, partition, activeKey)
|
||||
saveCachedRuntimeAndDetail(t, store, partition, skippedKey)
|
||||
saveCLIIDDetail(t, store, partition, "active")
|
||||
|
||||
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 _, _, err := store.LoadTools(partition, activeKey); err == nil {
|
||||
t.Fatal("LoadTools(active) error = nil, want cache cleared before failed refresh")
|
||||
}
|
||||
if _, _, err := store.LoadDetail(partition, activeKey); err == nil {
|
||||
t.Fatal("LoadDetail(active) error = nil, want detail cache cleared before failed refresh")
|
||||
}
|
||||
if _, _, err := store.LoadDetail(partition, "active"); err != nil {
|
||||
t.Fatalf("LoadDetail(active CLI.ID) error = %v, want CLI metadata preserved on failed refresh", err)
|
||||
}
|
||||
if _, _, err := store.LoadTools(partition, skippedKey); err == nil {
|
||||
t.Fatal("LoadTools(skipped) error = nil, want skipped service cache removed")
|
||||
}
|
||||
if _, _, err := store.LoadDetail(partition, skippedKey); err == nil {
|
||||
t.Fatal("LoadDetail(skipped) error = nil, want skipped service detail cache removed")
|
||||
}
|
||||
if got := skippedRuntimeCalls.Load(); got != 0 {
|
||||
t.Fatalf("skipped runtime calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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/bamboo":
|
||||
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()
|
||||
|
||||
payload, err := json.Marshal(market.DetailResponse{
|
||||
Success: true,
|
||||
Result: market.DetailResult{
|
||||
Tools: []market.DetailTool{
|
||||
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(cli detail payload) error = %v", err)
|
||||
}
|
||||
if err := store.SaveDetail(partition, cliID, cache.DetailSnapshot{
|
||||
MCPID: 0,
|
||||
Payload: payload,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDetail(%s) error = %v", cliID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveCachedRuntimeAndDetail(t *testing.T, store *cache.Store, partition, serverKey string) {
|
||||
t.Helper()
|
||||
|
||||
if err := store.SaveTools(partition, serverKey, cache.ToolsSnapshot{
|
||||
ServerKey: serverKey,
|
||||
ProtocolVersion: "2025-03-26",
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{Name: "stale_tool", Title: "Stale Tool"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTools(%s) error = %v", serverKey, err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(market.DetailResponse{
|
||||
Success: true,
|
||||
Result: market.DetailResult{
|
||||
Tools: []market.DetailTool{
|
||||
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(detail payload) error = %v", err)
|
||||
}
|
||||
if err := store.SaveDetail(partition, serverKey, cache.DetailSnapshot{
|
||||
MCPID: 0,
|
||||
Payload: payload,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDetail(%s) error = %v", serverKey, err)
|
||||
}
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"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/market"
|
||||
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()
|
||||
|
||||
root := NewRootCommand()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation(
|
||||
"bad flag",
|
||||
apperrors.WithHint("Pass the required flag and retry."),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
if err := root.PersistentFlags().Set("format", "json"); err != nil {
|
||||
t.Fatalf("Set(format) error = %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation("bad flag"))
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
|
||||
server := mockmcp.DefaultServer()
|
||||
defer server.Close()
|
||||
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetArgs([]string{"mcp", "doc", "search_documents", "--json", "{"})
|
||||
|
||||
executed, execErr := root.ExecuteC()
|
||||
if execErr == nil {
|
||||
t.Fatal("ExecuteC() error = nil, want validation error")
|
||||
}
|
||||
if executed == nil {
|
||||
t.Fatal("ExecuteC() returned nil command")
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := printExecutionError(executed, &stdout, &stderr, execErr)
|
||||
if err != nil {
|
||||
t.Fatalf("printExecutionError() error = %v", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
|
||||
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionCommandUsesConfiguredWriter(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"completion", "bash"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "bash completion for dws") {
|
||||
t.Fatalf("output = %q, want completion script in configured writer", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandShowsHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"cache", "nonexistent-cmd"})
|
||||
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
t.Fatal("ExecuteC() error = nil, want unknown command error")
|
||||
}
|
||||
if !isUnknownCommandError(err) {
|
||||
t.Fatalf("isUnknownCommandError() = false for error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate what Execute() does: redirect output to stderr and print help
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
executed.SetOut(&out)
|
||||
_ = executed.Help()
|
||||
|
||||
combined := out.String()
|
||||
// Help text should include the parent command's usage
|
||||
if !strings.Contains(combined, "cache") {
|
||||
t.Fatalf("output should contain parent command name 'cache', got:\n%s", combined)
|
||||
}
|
||||
// Help text should list available subcommands
|
||||
if !strings.Contains(combined, "Available Commands") {
|
||||
t.Fatalf("output should contain 'Available Commands', got:\n%s", combined)
|
||||
}
|
||||
if !strings.Contains(combined, "refresh") {
|
||||
t.Fatalf("output should list 'refresh' subcommand, got:\n%s", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"version"})
|
||||
|
||||
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 line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
t.Setenv(cli.CacheDirEnv, cacheDir)
|
||||
store := cache.NewStore(cacheDir)
|
||||
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
|
||||
SavedAt: time.Now().UTC().Add(-2 * time.Hour),
|
||||
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"version"})
|
||||
|
||||
start := time.Now()
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(version) error = %v", err)
|
||||
}
|
||||
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 line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
|
||||
"create_ai_app": map[string]any{
|
||||
"cliName": "create",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"--help"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(--help) error = %v", err)
|
||||
}
|
||||
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) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
|
||||
"article": map[string]any{"description": "文档文章"},
|
||||
}, map[string]any{
|
||||
"search_article": map[string]any{
|
||||
"cliName": "search",
|
||||
"group": "article",
|
||||
"flags": map[string]any{},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"-h"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(-h) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Discovered MCP Services:") {
|
||||
t.Fatalf("root short help output missing MCP summary:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
response := map[string]any{
|
||||
"metadata": map[string]any{"count": 1, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
|
||||
"article": map[string]any{"description": "文档文章"},
|
||||
}, map[string]any{
|
||||
"search_article": map[string]any{
|
||||
"cliName": "search",
|
||||
"group": "article",
|
||||
"flags": map[string]any{
|
||||
"keyword": map[string]any{"alias": "keyword"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
SetDiscoveryBaseURL(srv.URL)
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"devdoc", "article", "search", "-h"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(devdoc article search -h) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "搜索开放平台文档") || !strings.Contains(out.String(), "dws devdoc article search") {
|
||||
t.Fatalf("nested short help output missing command help:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
@@ -86,6 +87,7 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
renderRootGlobalFlags(root)
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
|
||||
// Render root.Long after the command list so agents see the upgrade
|
||||
@@ -99,6 +101,53 @@ func renderRootHelp(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
flags := visiblePersistentFlags(root)
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
w := root.OutOrStdout()
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, flag := range flags {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
flags := make([]*pflag.Flag, 0)
|
||||
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag == nil || flag.Hidden {
|
||||
return
|
||||
}
|
||||
flags = append(flags, flag)
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func formatRootFlag(flag *pflag.Flag) string {
|
||||
if flag == nil {
|
||||
return ""
|
||||
}
|
||||
name := "--" + flag.Name
|
||||
if flag.Value != nil && flag.Value.Type() != "bool" {
|
||||
name += " " + flag.Value.Type()
|
||||
}
|
||||
if flag.Shorthand == "" {
|
||||
return " " + name
|
||||
}
|
||||
return "-" + flag.Shorthand + ", " + name
|
||||
}
|
||||
|
||||
func commandShort(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// 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"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("root help: %v\n%s", err, out.String())
|
||||
}
|
||||
help := out.String()
|
||||
if strings.Contains(help, "● conference") {
|
||||
t.Fatalf("root help should hide conference compatibility command:\n%s", help)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"● dev",
|
||||
"• upgrade",
|
||||
} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
||||
if listDirect.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat message list-direct missing --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
mediaUpload := mustFindCommand(t, root, "chat", "media", "upload")
|
||||
for _, flag := range []string{"file", "type"} {
|
||||
if mediaUpload.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat media upload missing --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
mustFindCommand(t, root, "contact", "get")
|
||||
mustFindCommand(t, root, "contact", "search")
|
||||
mustFindCommand(t, root, "contact", "user", "list")
|
||||
mustFindCommand(t, root, "conference", "meeting", "reserve")
|
||||
}
|
||||
|
||||
func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
label := mustFindCommand(t, root, "contact", "label")
|
||||
if label.Hidden {
|
||||
t.Fatal("contact label should be visible as a real command group")
|
||||
}
|
||||
if !containsString(label.Aliases, "role") {
|
||||
t.Fatal("contact label missing role alias")
|
||||
}
|
||||
mustFindCommand(t, root, "contact", "label", "get")
|
||||
mustFindCommand(t, root, "contact", "label", "list")
|
||||
mustFindCommand(t, root, "contact", "label", "list-members")
|
||||
mustFindCommand(t, root, "contact", "label", "find")
|
||||
mustFindCommand(t, root, "contact", "label", "search")
|
||||
mustFindCommand(t, root, "contact", "label", "info")
|
||||
mustFindCommand(t, root, "contact", "label", "detail")
|
||||
mustFindCommand(t, root, "contact", "label", "list-all")
|
||||
|
||||
getSelf := mustFindCommand(t, root, "contact", "user", "get-self")
|
||||
for _, alias := range []string{"self", "me", "whoami", "current"} {
|
||||
if !containsString(getSelf.Aliases, alias) {
|
||||
t.Fatalf("contact user get-self missing alias %q", alias)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "label list",
|
||||
args: []string{"--dry-run", "contact", "label", "list"},
|
||||
want: []string{"get_org_labels"},
|
||||
},
|
||||
{
|
||||
name: "label get",
|
||||
args: []string{"--dry-run", "contact", "label", "get", "--names", "admin,finance"},
|
||||
want: []string{"search_label_by_name", "labelNames", "admin", "finance"},
|
||||
},
|
||||
{
|
||||
name: "label members",
|
||||
args: []string{"--dry-run", "contact", "label", "list-members", "--id", "123"},
|
||||
want: []string{"get_label_members_by_labelId", "labelId", "123"},
|
||||
},
|
||||
{
|
||||
name: "role shim",
|
||||
args: []string{"--dry-run", "contact", "role", "list"},
|
||||
want: []string{"get_org_labels"},
|
||||
},
|
||||
{
|
||||
name: "label fuzzy shim",
|
||||
args: []string{"--dry-run", "contact", "label", "find", "--names", "admin"},
|
||||
want: []string{"search_label_by_name", "labelNames", "admin"},
|
||||
},
|
||||
{
|
||||
name: "label detail shim",
|
||||
args: []string{"--dry-run", "contact", "label", "detail", "--id", "123"},
|
||||
want: []string{"get_label_members_by_labelId", "labelId", "123"},
|
||||
},
|
||||
{
|
||||
name: "contact search shim",
|
||||
args: []string{"--dry-run", "contact", "search", "--query", "admin"},
|
||||
want: []string{"search_contact_by_key_word", "keyword", "admin"},
|
||||
},
|
||||
{
|
||||
name: "contact find shim",
|
||||
args: []string{"--dry-run", "contact", "find", "--query", "admin"},
|
||||
want: []string{"search_contact_by_key_word", "keyword", "admin"},
|
||||
},
|
||||
{
|
||||
name: "contact list defaults to label list",
|
||||
args: []string{"--dry-run", "contact", "list"},
|
||||
want: []string{"get_org_labels"},
|
||||
},
|
||||
{
|
||||
name: "contact list department members",
|
||||
args: []string{"--dry-run", "contact", "list", "--depts", "1"},
|
||||
want: []string{"get_dept_members_by_deptId", "deptIds", "1"},
|
||||
},
|
||||
{
|
||||
name: "contact get user details",
|
||||
args: []string{"--dry-run", "contact", "get", "--ids", "user1"},
|
||||
want: []string{"get_user_info_by_user_ids", "user_id_list", "user1"},
|
||||
},
|
||||
{
|
||||
name: "contact get label by name",
|
||||
args: []string{"--dry-run", "contact", "get", "--names", "admin"},
|
||||
want: []string{"search_label_by_name", "labelNames", "admin"},
|
||||
},
|
||||
{
|
||||
name: "contact self shim",
|
||||
args: []string{"--dry-run", "contact", "self"},
|
||||
want: []string{"get_current_user_profile"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, tc.args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(%v) error = %v\n%s", tc.args, err, got)
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("Execute(%v) output missing %q:\n%s", tc.args, want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
fileCmd := mustFindCommand(t, root, "chat", "file")
|
||||
if !fileCmd.Hidden {
|
||||
t.Fatal("chat file should be hidden after upload_conversation_file_by_url downline")
|
||||
}
|
||||
upload := mustFindCommand(t, root, "chat", "file", "upload")
|
||||
if !upload.Hidden {
|
||||
t.Fatal("chat file upload should be hidden after downline")
|
||||
}
|
||||
for _, flag := range []string{"group", "url", "file", "file-name"} {
|
||||
if upload.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat file upload missing compatibility flag --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
send := mustFindCommand(t, root, "chat", "message", "send")
|
||||
for _, flag := range []string{"msg-type", "file-path"} {
|
||||
if send.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat message send missing --%s", flag)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"chat", "file", "upload",
|
||||
"--group", "cid",
|
||||
"--url", "https://example.com/report.pdf",
|
||||
"--file-name", "report.pdf",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
|
||||
}
|
||||
got = got + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "list",
|
||||
"--start", "2026-07-07T00:00:00+08:00",
|
||||
"--end", "2026-07-07T01:00:00+08:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event list --dry-run error = %v\n%s", err, got)
|
||||
}
|
||||
for _, want := range []string{"list_calendar_events", "startTime", "endTime"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("calendar dry-run output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
|
||||
listBySender := mustFindCommand(t, root, "chat", "message", "list-by-sender")
|
||||
if listBySender.Flags().Lookup("sender") == nil {
|
||||
t.Fatal("chat message list-by-sender missing hidden --sender alias")
|
||||
}
|
||||
|
||||
searchAdvanced := mustFindCommand(t, root, "chat", "message", "search-advanced")
|
||||
for _, flag := range []string{"sender", "senders", "sender-ids"} {
|
||||
if searchAdvanced.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("chat message search-advanced missing --%s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshCompatibilityStub(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
defer edition.Override(previous)
|
||||
defer SetDynamicServers(nil)
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "test",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "static-test",
|
||||
Name: "Static Test",
|
||||
Endpoint: "https://static.example/server/static-test",
|
||||
Prefixes: []string{"static-alias"},
|
||||
}}
|
||||
},
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "supplement-test",
|
||||
Name: "Supplement Test",
|
||||
Endpoint: "https://supplement.example/server/supplement-test",
|
||||
Prefixes: []string{"supplement-alias"},
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
injectStaticServers()
|
||||
|
||||
for _, tc := range []struct {
|
||||
productID string
|
||||
endpoint string
|
||||
}{
|
||||
{"static-test", "https://static.example/server/static-test"},
|
||||
{"static-alias", "https://static.example/server/static-test"},
|
||||
{"supplement-test", "https://supplement.example/server/supplement-test"},
|
||||
{"supplement-alias", "https://supplement.example/server/supplement-test"},
|
||||
} {
|
||||
got, ok := directRuntimeEndpoint(tc.productID, "")
|
||||
if !ok || got != tc.endpoint {
|
||||
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", tc.productID, got, ok, tc.endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := root
|
||||
for _, name := range path {
|
||||
var next *cobra.Command
|
||||
for _, child := range cmd.Commands() {
|
||||
if child.Name() == name {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatalf("missing command path %q under %q", strings.Join(path, " "), cmd.CommandPath())
|
||||
}
|
||||
cmd = next
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func executeRootCaptureStdout(t *testing.T, args []string) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
readPipe, writePipe, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe error = %v", err)
|
||||
}
|
||||
os.Stdout = writePipe
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(args)
|
||||
execErr := cmd.Execute()
|
||||
|
||||
_ = writePipe.Close()
|
||||
os.Stdout = oldStdout
|
||||
captured, readErr := io.ReadAll(readPipe)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read stdout pipe error = %v", readErr)
|
||||
}
|
||||
return out.String() + string(captured), execErr
|
||||
}
|
||||
+245
-31
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
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"
|
||||
@@ -141,6 +143,7 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
scanner: newRuntimeContentScanner(),
|
||||
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
||||
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
||||
auditSink: setupAuditSink(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,15 +155,40 @@ type runtimeRunner struct {
|
||||
scanner safety.Scanner
|
||||
enforceContentScan bool
|
||||
includeScanReport bool
|
||||
auditSink audit.Sink
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
// Global dry-run is an execution barrier, not merely a transport option.
|
||||
// Return a deterministic local preview before profile resolution, catalog
|
||||
// discovery, Keychain/token prefetch, auth, stateful preflight or transport.
|
||||
// Use the non-injectable EchoRunner rather than r.fallback so tests and
|
||||
// edition overlays cannot accidentally turn this path into real execution.
|
||||
if invocation.DryRun || (r != nil && r.globalFlags != nil && r.globalFlags.DryRun) {
|
||||
invocation.DryRun = true
|
||||
return (executor.EchoRunner{}).Run(ctx, invocation)
|
||||
}
|
||||
if r == nil {
|
||||
return executor.Result{}, fmt.Errorf("runtime runner is not configured")
|
||||
}
|
||||
// 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()
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if multi {
|
||||
return r.runMultiProfile(ctx, invocation, selections)
|
||||
}
|
||||
|
||||
return r.runSingle(ctx, invocation, true)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
@@ -178,7 +206,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
go getCachedRuntimeToken(ctx)
|
||||
if prefetchToken {
|
||||
go getCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
@@ -238,6 +268,144 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
type multiProfileSelection struct {
|
||||
Selector string
|
||||
Profile authpkg.Profile
|
||||
}
|
||||
|
||||
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
|
||||
rawSelector = strings.TrimSpace(rawSelector)
|
||||
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
|
||||
return nil, false, nil
|
||||
}
|
||||
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(rawSelector, ",")
|
||||
selections := make([]multiProfileSelection, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
selector := strings.TrimSpace(part)
|
||||
if selector == "" {
|
||||
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
|
||||
}
|
||||
profile, err := authpkg.ResolveProfile(configDir, selector)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, false, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
if seen[profile.CorpID] {
|
||||
continue
|
||||
}
|
||||
seen[profile.CorpID] = true
|
||||
selections = append(selections, multiProfileSelection{
|
||||
Selector: selector,
|
||||
Profile: *profile,
|
||||
})
|
||||
}
|
||||
if len(selections) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return selections, true, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
defer authpkg.SetRuntimeProfile(previousProfile)
|
||||
|
||||
entries := make([]any, 0, len(selections))
|
||||
succeeded := 0
|
||||
failed := 0
|
||||
|
||||
for _, selection := range selections {
|
||||
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
entry := map[string]any{
|
||||
"selector": selection.Selector,
|
||||
"corpId": selection.Profile.CorpID,
|
||||
"corpName": selection.Profile.CorpName,
|
||||
"ok": err == nil,
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
entry["error"] = multiProfileErrorPayload(err)
|
||||
} else {
|
||||
succeeded++
|
||||
if payload := multiProfileResultPayload(result); payload != nil {
|
||||
entry["result"] = payload
|
||||
}
|
||||
if result.Response != nil {
|
||||
if endpoint, ok := result.Response["endpoint"]; ok {
|
||||
entry["endpoint"] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": failed == 0,
|
||||
"multiProfile": true,
|
||||
"summary": map[string]any{
|
||||
"total": len(selections),
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
},
|
||||
"profiles": entries,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
|
||||
cloned := invocation
|
||||
if invocation.Params != nil {
|
||||
cloned.Params = make(map[string]any, len(invocation.Params))
|
||||
for key, value := range invocation.Params {
|
||||
cloned.Params[key] = value
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func multiProfileResultPayload(result executor.Result) any {
|
||||
if result.Response == nil {
|
||||
return nil
|
||||
}
|
||||
if content, ok := result.Response["content"]; ok {
|
||||
return content
|
||||
}
|
||||
return result.Response
|
||||
}
|
||||
|
||||
func multiProfileErrorPayload(err error) map[string]any {
|
||||
payload := map[string]any{
|
||||
"message": err.Error(),
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
payload["category"] = string(typed.Category)
|
||||
if typed.Reason != "" {
|
||||
payload["reason"] = typed.Reason
|
||||
}
|
||||
if typed.Operation != "" {
|
||||
payload["operation"] = typed.Operation
|
||||
}
|
||||
if code := typed.ExitCode(); code != 0 {
|
||||
payload["exitCode"] = code
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
//
|
||||
@@ -259,12 +427,16 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
hint := "产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"
|
||||
actions := []string{"dws cache refresh"}
|
||||
hint := "当前命令已注册,但静态端点目录中缺少对应 product/server endpoint。这通常是服务发现下线后的同步产物缺口,不是参数错误;请不要通过反复调整 flag 重试。"
|
||||
actions := []string{
|
||||
"确认 internal/syncdata.StaticServers() 是否包含该 product/server",
|
||||
"运行 sync-oss 重新生成静态端点与路由",
|
||||
"若该能力已下线,请在 skill 与 --help 中标记 unavailable 并提供替代命令",
|
||||
}
|
||||
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖服务发现;真实调用需要通过 StaticServers/SupplementServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
actions = []string{
|
||||
"检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint",
|
||||
"检查 StaticServers/SupplementServers 是否包含 devapp endpoint",
|
||||
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
|
||||
}
|
||||
}
|
||||
@@ -311,6 +483,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
emitAudit(r.auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
@@ -333,6 +506,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
|
||||
// surface advertises the resolved MCP arguments without polluting the
|
||||
// stdout payload (which stays valid JSON in --format json mode). Mirrors
|
||||
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
|
||||
// machine-readable channel.
|
||||
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
@@ -492,6 +673,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
// Align with wukong's response envelope: stamp a top-level success=true on
|
||||
// map payloads that don't already carry a success flag. Business errors
|
||||
// (success=false) are intercepted above, so reaching here means the call
|
||||
// succeeded. Additive only — existing keys are never overwritten.
|
||||
if callResult.Content != nil {
|
||||
if _, has := callResult.Content["success"]; !has {
|
||||
callResult.Content["success"] = true
|
||||
}
|
||||
}
|
||||
response := map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
"content": callResult.Content,
|
||||
@@ -530,6 +720,13 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
if err := client.EnsureInitialized(callCtx); err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("stdio initialize failed: %v", err),
|
||||
apperrors.WithOperation("initialize"),
|
||||
apperrors.WithReason("stdio_initialize_error"),
|
||||
)
|
||||
}
|
||||
|
||||
callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params)
|
||||
if err != nil {
|
||||
@@ -586,28 +783,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
||||
|
||||
// Cached token state for process lifetime
|
||||
var (
|
||||
cachedRuntimeToken string
|
||||
cachedRuntimeTokenOnce sync.Once
|
||||
cachedRuntimeTokenMu sync.Mutex
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
)
|
||||
|
||||
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
|
||||
// This avoids repeated Keychain access which takes ~70ms each time.
|
||||
func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cachedRuntimeTokenOnce.Do(func() {
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
|
||||
if cacheKey == "" {
|
||||
cacheKey = "__default__"
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
if token := cachedRuntimeTokens[cacheKey]; token != "" {
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return
|
||||
}
|
||||
if token != "" {
|
||||
cachedRuntimeToken = token
|
||||
}
|
||||
})
|
||||
return cachedRuntimeToken
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return ""
|
||||
}
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
cachedRuntimeTokens[cacheKey] = token
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
@@ -622,8 +831,9 @@ func generateExecutionID() string {
|
||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||
// This should be called after login/logout operations.
|
||||
func ResetRuntimeTokenCache() {
|
||||
cachedRuntimeTokenOnce = sync.Once{}
|
||||
cachedRuntimeToken = ""
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
defer cachedRuntimeTokenMu.Unlock()
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
}
|
||||
|
||||
func newRuntimeContentScanner() safety.Scanner {
|
||||
@@ -696,20 +906,21 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envRewindSessionID)
|
||||
}
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
|
||||
// per-(machine × agent_code) instance id. This is what makes agent_code
|
||||
// actually report a value: previously it was sent only when the host
|
||||
// injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
|
||||
// gateway logged no agent_code at all. DetectAgentCode always yields a code.
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the
|
||||
// per-(machine × agent_code) instance id when a code is known. Synthetic
|
||||
// fallbacks must not be sent because PAT authorization checks use the same
|
||||
// header as their grant key.
|
||||
//
|
||||
// Backward-compat by design (additive, not breaking):
|
||||
// - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
|
||||
// (set by id.Headers() above), so old/new clients stay comparable.
|
||||
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id.
|
||||
// Old clients don't send it, which is itself a clean old/new signal.
|
||||
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id,
|
||||
// sent only when x-dingtalk-dws-agent-code is non-empty.
|
||||
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
|
||||
agentCode, agentCodeSig := authpkg.DetectAgentCode()
|
||||
headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig)
|
||||
if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" {
|
||||
headers["x-dws-agent-instance-id"] = agentInstanceID
|
||||
}
|
||||
|
||||
// Emit the CLI version on the wire so the gateway can segment old vs new
|
||||
// clients (and scope agent_code coverage / adoption). The header constant
|
||||
@@ -739,6 +950,9 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package 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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
var (
|
||||
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
|
||||
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
|
||||
)
|
||||
|
||||
// TestManualAgentExamplesContract is the always-on gate. It validates every
|
||||
// example, including contract_only entries, against the live bound Cobra path,
|
||||
// flags, required arguments, constraints, and final typed safety.
|
||||
func TestManualAgentExamplesContract(t *testing.T) {
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Logf("Agent example contract: total=%d contract=%d dry_run=%d contract_only=%d", plan.Total, plan.Contract, plan.DryRun, plan.ContractOnly)
|
||||
}
|
||||
|
||||
// TestManualAgentExamplesDryRun first validates every reviewed example against
|
||||
// its real BoundCommand, Cobra required arguments, and final typed constraints.
|
||||
// It then executes only the deterministic, explicitly declared dry_run subset
|
||||
// without injecting --yes. Global flag inheritance is not treated as capability
|
||||
// evidence. Runtime failures never create implicit skips. No shell is involved
|
||||
// and HOME is isolated.
|
||||
func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_EXAMPLES_DRY_RUN") != "1" {
|
||||
t.Skip("set DWS_AGENT_EXAMPLES_DRY_RUN=1 to execute the explicitly reviewed Agent dry-run subset")
|
||||
}
|
||||
|
||||
sandboxRoot := t.TempDir()
|
||||
homeDir := filepath.Join(sandboxRoot, "home")
|
||||
configDir := filepath.Join(sandboxRoot, "config")
|
||||
for _, dir := range []string{homeDir, configDir} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("create isolated test directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Chdir(sandboxRoot)
|
||||
files := newManualAgentExampleFiles(t, sandboxRoot)
|
||||
|
||||
selected := 0
|
||||
executed := 0
|
||||
for _, execution := range plan.Examples {
|
||||
if !manualAgentExampleShouldExerciseDryRun(execution) {
|
||||
continue
|
||||
}
|
||||
selected++
|
||||
execution := execution
|
||||
t.Run(fmt.Sprintf("%s/%d", strings.ReplaceAll(execution.CanonicalPath, ".", "/"), execution.Index), func(t *testing.T) {
|
||||
argv, err := cli.ParseManualAgentExampleArgv(execution.Example)
|
||||
if err != nil {
|
||||
t.Fatalf("parse example %q: %v", execution.Example, err)
|
||||
}
|
||||
args := materializeManualAgentExampleArgv(argv[1:], files)
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
t.Fatalf("dry-run gate must not inject or accept --yes\nsource: %s\nargv: %q", execution.Example, args)
|
||||
}
|
||||
if !manualAgentExampleHasFlag(args, "dry-run") {
|
||||
args = append([]string{"--dry-run"}, args...)
|
||||
}
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("eligible dry-run attempted %d ToolCaller invocation(s)\nsource: %s\nargv: %q\noutput:\n%s", capture.ToolCallAttempts, execution.Example, args, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("eligible dry-run entered an interactive confirmation path (stdin bytes read: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.StdinBytesRead, execution.Example, args, capture.Output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run example failed: %v\nsource: %s\nargv: %q\noutput:\n%s", err, execution.Example, args, capture.Output)
|
||||
}
|
||||
previewKind, observed := manualAgentExampleDryRunEvidence(capture)
|
||||
if !observed {
|
||||
t.Fatalf("example returned without audited dry-run evidence (caller dry-run checks: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.DryRunChecks, execution.Example, args, capture.Output)
|
||||
}
|
||||
if want := execution.DryRun.PreviewKind; previewKind != want {
|
||||
t.Fatalf("dry-run preview kind = %q, Schema declares %q\nsource: %s\nargv: %q\noutput:\n%s", previewKind, want, execution.Example, args, capture.Output)
|
||||
}
|
||||
t.Logf("dry_run_capability_candidate=%s", previewKind)
|
||||
executed++
|
||||
})
|
||||
}
|
||||
if executed != selected {
|
||||
t.Fatalf("executed dry_run examples = %d, selected capability set requires %d", executed, selected)
|
||||
}
|
||||
t.Logf("Agent examples: total=%d contract=%d dry_run_selected=%d planned_dry_run=%d contract_only=%d reviewed_manual=%d", plan.Total, plan.Contract, selected, plan.DryRun, plan.ContractOnly, plan.ReviewedContractOnly)
|
||||
reasonCodes := make([]string, 0, len(plan.ContractOnlyByReason))
|
||||
for reasonCode := range plan.ContractOnlyByReason {
|
||||
reasonCodes = append(reasonCodes, string(reasonCode))
|
||||
}
|
||||
sort.Strings(reasonCodes)
|
||||
for _, reasonCode := range reasonCodes {
|
||||
t.Logf("Agent examples contract_only[%s]=%d", reasonCode, plan.ContractOnlyByReason[cli.ManualAgentExampleReasonCode(reasonCode)])
|
||||
}
|
||||
}
|
||||
|
||||
// manualAgentExampleShouldExerciseDryRun is the single selection boundary for
|
||||
// the runtime gate. Capability comes only from the final typed ToolSpec; the
|
||||
// example disposition may narrow that set but can never invent support.
|
||||
func manualAgentExampleShouldExerciseDryRun(execution cli.ManualAgentExampleExecution) bool {
|
||||
return execution.DryRun != nil && execution.Mode == cli.ManualAgentExampleModeDryRun
|
||||
}
|
||||
|
||||
func manualAgentExampleExecutionPlan(t testing.TB) cli.ManualAgentExampleExecutionPlan {
|
||||
t.Helper()
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
contractRoot := NewRootCommand()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(contractRoot); err != nil {
|
||||
t.Fatalf("ApplyEmbeddedManualSchemaHints() error = %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(contractRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(contractRoot, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
registry, err := cli.AssembleSchemaRegistryFromBound(bound)
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleSchemaRegistryFromBound() error = %v", err)
|
||||
}
|
||||
if err := cli.ValidateReviewedDryRunCapabilityDelivery(registry); err != nil {
|
||||
t.Fatalf("ValidateReviewedDryRunCapabilityDelivery() error = %v", err)
|
||||
}
|
||||
plan, err := cli.BuildManualAgentExampleExecutionPlan(bound, registry, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentExampleExecutionPlan() error = %v", err)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
type manualAgentExampleCapture struct {
|
||||
Output string
|
||||
DryRunChecks int64
|
||||
ToolCallAttempts int64
|
||||
StdinBytesRead int64
|
||||
}
|
||||
|
||||
type manualAgentExampleFailClosedCaller struct {
|
||||
dryRunChecks atomic.Int64
|
||||
toolCallAttempts atomic.Int64
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.toolCallAttempts.Add(1)
|
||||
return nil, fmt.Errorf("real ToolCaller invocation blocked during Agent example dry-run: %s/%s", productID, toolName)
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Format() string { return "json" }
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) DryRun() bool {
|
||||
c.dryRunChecks.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Fields() string { return "" }
|
||||
func (c *manualAgentExampleFailClosedCaller) JQ() string { return "" }
|
||||
|
||||
func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentExampleCapture, error) {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
defer func() { os.Args = oldArgs }()
|
||||
oldStdin := os.Stdin
|
||||
promptInput, err := os.CreateTemp(t.TempDir(), "agent-example-stdin-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("open guarded stdin: %v", err)
|
||||
}
|
||||
defer promptInput.Close()
|
||||
if _, err := promptInput.WriteString("no\n"); err != nil {
|
||||
t.Fatalf("seed guarded stdin: %v", err)
|
||||
}
|
||||
if _, err := promptInput.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind guarded stdin: %v", err)
|
||||
}
|
||||
os.Stdin = promptInput
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
oldStdout, oldStderr := os.Stdout, os.Stderr
|
||||
oldColorOutput, oldColorError := color.Output, color.Error
|
||||
captureFile, err := os.CreateTemp(t.TempDir(), "agent-example-output-*.log")
|
||||
if err != nil {
|
||||
t.Fatalf("open output capture file: %v", err)
|
||||
}
|
||||
defer captureFile.Close()
|
||||
os.Stdout, os.Stderr = captureFile, captureFile
|
||||
color.Output, color.Error = captureFile, captureFile
|
||||
defer func() {
|
||||
os.Stdout, os.Stderr = oldStdout, oldStderr
|
||||
color.Output, color.Error = oldColorOutput, oldColorError
|
||||
}()
|
||||
|
||||
root := NewRootCommand()
|
||||
originalCaller := helpers.GetCaller()
|
||||
auditCaller := &manualAgentExampleFailClosedCaller{}
|
||||
helpers.InitDeps(auditCaller)
|
||||
defer helpers.InitDeps(originalCaller)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
root.SetArgs(args)
|
||||
execErr := root.Execute()
|
||||
|
||||
os.Stdout, os.Stderr = oldStdout, oldStderr
|
||||
color.Output, color.Error = oldColorOutput, oldColorError
|
||||
if _, err := captureFile.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind output capture file: %v", err)
|
||||
}
|
||||
captured, readErr := io.ReadAll(captureFile)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output capture file: %v", readErr)
|
||||
}
|
||||
stdinBytesRead, err := promptInput.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect guarded stdin: %v", err)
|
||||
}
|
||||
return manualAgentExampleCapture{
|
||||
Output: output.String() + string(captured),
|
||||
DryRunChecks: auditCaller.dryRunChecks.Load(),
|
||||
ToolCallAttempts: auditCaller.toolCallAttempts.Load(),
|
||||
StdinBytesRead: stdinBytesRead,
|
||||
}, execErr
|
||||
}
|
||||
|
||||
type manualAgentExampleFiles struct {
|
||||
root string
|
||||
markdown string
|
||||
json string
|
||||
batch string
|
||||
binary string
|
||||
image string
|
||||
}
|
||||
|
||||
func newManualAgentExampleFiles(t testing.TB, root string) manualAgentExampleFiles {
|
||||
t.Helper()
|
||||
markdown := filepath.Join(root, "content.md")
|
||||
jsonFile := filepath.Join(root, "report.json")
|
||||
batch := filepath.Join(root, "styles.json")
|
||||
binary := filepath.Join(root, "report.pdf")
|
||||
image := filepath.Join(root, "chart.png")
|
||||
for path, content := range map[string][]byte{
|
||||
markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"),
|
||||
jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`),
|
||||
batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`),
|
||||
binary: []byte("%PDF-1.4\n%%EOF\n"),
|
||||
image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'},
|
||||
} {
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return manualAgentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
}
|
||||
|
||||
func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFiles) []string {
|
||||
result := append([]string(nil), argv...)
|
||||
for index := range result {
|
||||
result[index] = manualAgentExamplePlaceholderPattern.ReplaceAllStringFunc(result[index], func(match string) string {
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(match, "<"), ">")
|
||||
switch strings.ToLower(name) {
|
||||
case "basetime", "remindertimestamp", "reminder-time-stamp":
|
||||
return "1780000000000"
|
||||
case "duedateoffset", "due-date-offset":
|
||||
return "0"
|
||||
case "reminderrules", "reminder-rules":
|
||||
return `[{"remindType":"minute","remindTime":10}]`
|
||||
case "filepath", "file-path":
|
||||
return files.binary
|
||||
case "uuid1,uuid2":
|
||||
return "uuid1,uuid2"
|
||||
default:
|
||||
clean := strings.NewReplacer(",", "_", "-", "_", ".", "_").Replace(name)
|
||||
return "test_" + clean
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for index := 0; index < len(result); index++ {
|
||||
name, inline, ok := manualAgentExampleLongFlag(result[index])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
valueIndex := index + 1
|
||||
value := inline
|
||||
if inline == "" && valueIndex < len(result) {
|
||||
value = result[valueIndex]
|
||||
}
|
||||
replacement := ""
|
||||
switch name {
|
||||
case "file", "file-path":
|
||||
if strings.Contains(strings.ToLower(value), "png") {
|
||||
replacement = files.image
|
||||
} else {
|
||||
replacement = files.binary
|
||||
}
|
||||
case "content-file":
|
||||
replacement = files.markdown
|
||||
case "contents-file":
|
||||
replacement = files.json
|
||||
case "batch":
|
||||
if strings.HasSuffix(strings.ToLower(value), "styles.json") {
|
||||
replacement = files.batch
|
||||
}
|
||||
case "output":
|
||||
if value == "." || value == "" {
|
||||
replacement = files.root
|
||||
} else {
|
||||
replacement = filepath.Join(files.root, filepath.Base(value))
|
||||
}
|
||||
}
|
||||
if replacement == "" {
|
||||
continue
|
||||
}
|
||||
if inline != "" {
|
||||
result[index] = "--" + name + "=" + replacement
|
||||
} else if valueIndex < len(result) {
|
||||
result[valueIndex] = replacement
|
||||
index++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func manualAgentExampleLongFlag(argument string) (name, inline string, ok bool) {
|
||||
if !strings.HasPrefix(argument, "--") {
|
||||
return "", "", false
|
||||
}
|
||||
name, inline, _ = strings.Cut(strings.TrimPrefix(argument, "--"), "=")
|
||||
return name, inline, name != ""
|
||||
}
|
||||
|
||||
func manualAgentExampleHasFlag(argv []string, target string) bool {
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+target || strings.HasPrefix(argument, "--"+target+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool {
|
||||
_, ok := manualAgentExampleDryRunEvidence(capture)
|
||||
return ok
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) {
|
||||
normalized := strings.ToLower(capture.Output)
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewRequest, true
|
||||
}
|
||||
if strings.Contains(normalized, "[dry-run]") {
|
||||
return cli.DryRunPreviewInvocation, true
|
||||
}
|
||||
if capture.DryRunChecks > 0 && strings.Contains(capture.Output, "操作:") {
|
||||
return cli.DryRunPreviewPlan, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing.T) {
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "[DRY-RUN] Preview only, not executed:\nTool: calendar_list"}) {
|
||||
t.Fatal("dry-run output with a Tool and nil Arguments was not recognized")
|
||||
}
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "Tool: calendar_list"}) {
|
||||
t.Fatal("a Tool line without dry-run evidence must not be accepted")
|
||||
}
|
||||
for _, falseEvidence := range []string{
|
||||
"unknown flag: --dry-run",
|
||||
"Run again with --dry-run to preview the operation",
|
||||
`{"dry_run":false,"executed":true}`,
|
||||
} {
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: falseEvidence}) {
|
||||
t.Errorf("non-evidence text was mistaken for a successful dry-run: %q", falseEvidence)
|
||||
}
|
||||
}
|
||||
operationSummary := "操作: 下载钉盘文件\n文件ID: test"
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary}) {
|
||||
t.Fatal("a human-only operation summary without an audited dry-run check must not be accepted")
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary, DryRunChecks: 1}) {
|
||||
t.Fatal("a command plan guarded by the injected caller's dry-run check was not recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func manualAgentExamplePromptObserved(output string) bool {
|
||||
normalized := strings.ToLower(output)
|
||||
for _, marker := range []string{
|
||||
"confirm ",
|
||||
"confirm deletion?",
|
||||
"confirm action?",
|
||||
"confirm create?",
|
||||
"confirm update?",
|
||||
"confirm save?",
|
||||
"confirm import?",
|
||||
"are you sure",
|
||||
"operation cancelled",
|
||||
"操作已取消",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testing.T) {
|
||||
for _, prompt := range []string{
|
||||
"Confirm deletion? (yes/no):",
|
||||
"Confirm action? (yes/no):",
|
||||
"Confirm create? (yes/no):",
|
||||
"Confirm update? (yes/no):",
|
||||
"Confirm save? (yes/no):",
|
||||
"Confirm import? (yes/no):",
|
||||
"Are you sure you want to continue?",
|
||||
"Operation cancelled",
|
||||
} {
|
||||
if !manualAgentExamplePromptObserved(prompt) {
|
||||
t.Errorf("interactive confirmation output was not detected: %q", prompt)
|
||||
}
|
||||
}
|
||||
if manualAgentExamplePromptObserved(`{"dry_run":true,"confirmation":"user_required"}`) {
|
||||
t.Fatal("typed safety metadata was mistaken for an interactive prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableAdvpermDisableDryRunSkipsConfirmationAndToolCall(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
args := []string{
|
||||
"--dry-run", "--format", "json",
|
||||
"aitable", "advperm", "disable",
|
||||
"--base-id", "BASE_ID",
|
||||
}
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
t.Fatal("regression test must not bypass confirmation with --yes")
|
||||
}
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
if err != nil {
|
||||
t.Fatalf("advperm disable fail-closed dry-run failed: %v\noutput:\n%s", err, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("advperm disable dry-run entered confirmation (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("advperm disable dry-run attempted %d real ToolCaller invocation(s)\noutput:\n%s", capture.ToolCallAttempts, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("advperm disable returned no audited dry-run evidence (caller dry-run checks: %d)\noutput:\n%s", capture.DryRunChecks, capture.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
caller := &manualAgentExampleFailClosedCaller{}
|
||||
if !caller.DryRun() {
|
||||
t.Fatal("fail-closed caller must advertise dry-run mode")
|
||||
}
|
||||
if _, err := caller.CallTool(context.Background(), "calendar", "list_events", nil); err == nil {
|
||||
t.Fatal("fail-closed caller accepted a ToolCaller invocation")
|
||||
}
|
||||
if got := caller.dryRunChecks.Load(); got != 1 {
|
||||
t.Fatalf("DryRun() checks = %d, want 1", got)
|
||||
}
|
||||
if got := caller.toolCallAttempts.Load(); got != 1 {
|
||||
t.Fatalf("CallTool() attempts = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testing.T) {
|
||||
sandboxRoot := t.TempDir()
|
||||
configDir := filepath.Join(sandboxRoot, "config")
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatalf("create isolated config directory: %v", err)
|
||||
}
|
||||
t.Setenv("HOME", sandboxRoot)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, []string{
|
||||
"--dry-run",
|
||||
"chat", "group-mute-member",
|
||||
"--group", "test_openConversationId",
|
||||
"--users", "userId1,userId2",
|
||||
"--mute-time", "3600000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("group-mute-member dry-run failed: %v\noutput:\n%s", err, capture.Output)
|
||||
}
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("group-mute-member dry-run attempted %d ToolCaller invocation(s)\noutput:\n%s", capture.ToolCallAttempts, capture.Output)
|
||||
}
|
||||
if capture.DryRunChecks == 0 {
|
||||
t.Fatalf("group-mute-member did not enter its audited command dry-run path\noutput:\n%s", capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("group-mute-member dry-run entered an interactive prompt (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("group-mute-member returned no audited dry-run evidence\noutput:\n%s", capture.Output)
|
||||
}
|
||||
for _, expected := range []string{`"uids"`, `"userId1"`, `"userId2"`} {
|
||||
if !strings.Contains(capture.Output, expected) {
|
||||
t.Fatalf("group-mute-member command preview missing %s\noutput:\n%s", expected, capture.Output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(capture.Output, `"openDingTalkIds"`) {
|
||||
t.Fatalf("group-mute-member dry-run unexpectedly resolved user IDs remotely\noutput:\n%s", capture.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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/cli"
|
||||
)
|
||||
|
||||
func TestManualAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
|
||||
fixture, report, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
if report.Tools != len(bound.Commands) {
|
||||
t.Fatalf("selection tools = %d, bound commands = %d", report.Tools, len(bound.Commands))
|
||||
}
|
||||
if report.PositiveAssertions < report.Tools {
|
||||
t.Fatalf("positive selection coverage = %+v, want at least one assertion per tool", report)
|
||||
}
|
||||
if report.NegativeAssertions < report.Tools {
|
||||
t.Fatalf("negative selection coverage = %+v, want at least one assertion per tool", report)
|
||||
}
|
||||
if report.Tools == 0 {
|
||||
t.Fatal("selection contract unexpectedly contains no tools")
|
||||
}
|
||||
if len(fixture.Cases) != report.PositiveAssertions+report.NegativeAssertions {
|
||||
t.Fatalf("selection fixture cases = %d, report = %+v", len(fixture.Cases), report)
|
||||
}
|
||||
if report.FixtureSHA256 == "" {
|
||||
t.Fatal("selection fixture digest is empty")
|
||||
}
|
||||
t.Logf("validated %d bound tools, %d positive assertions, %d negative assertions (%s)", report.Tools, report.PositiveAssertions, report.NegativeAssertions, report.FixtureSHA256)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const manualAgentSelectionLiveBatchSize = 12
|
||||
|
||||
type manualAgentSelectionLiveCandidate struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
AgentSummary string `json:"agent_summary"`
|
||||
UseWhen []string `json:"use_when"`
|
||||
AvoidWhen []string `json:"avoid_when"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveInput struct {
|
||||
Cases []manualAgentSelectionLiveCase `json:"cases"`
|
||||
Candidates []manualAgentSelectionLiveCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// manualAgentSelectionLiveCase is deliberately answer-free. Expected and
|
||||
// forbidden canonicals stay only in the local assertion fixture and are never
|
||||
// sent to the model being evaluated.
|
||||
type manualAgentSelectionLiveCase struct {
|
||||
ID string `json:"id"`
|
||||
Scenario string `json:"scenario"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResult struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResponse struct {
|
||||
Results []manualAgentSelectionLiveResult `json:"results"`
|
||||
}
|
||||
|
||||
// TestManualAgentSelectionArkLive is intentionally opt-in. Deterministic CI
|
||||
// validates all fixture and Cobra facts without network access; this test asks
|
||||
// a real model to interpret the reviewed natural-language scenarios. Set
|
||||
// DWS_AGENT_SELECTION_FULL=1 to evaluate every positive and negative case.
|
||||
func TestManualAgentSelectionArkLive(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_SELECTION_LIVE") != "1" {
|
||||
t.Skip("set DWS_AGENT_SELECTION_LIVE=1 and ARK_API_KEY/ARK_BASE_URL/ARK_MODEL to run live Agent command-selection evaluation")
|
||||
}
|
||||
apiKey := strings.TrimSpace(os.Getenv("ARK_API_KEY"))
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("ARK_BASE_URL")), "/")
|
||||
model := strings.TrimSpace(os.Getenv("ARK_MODEL"))
|
||||
for name, value := range map[string]string{
|
||||
"ARK_API_KEY": apiKey,
|
||||
"ARK_BASE_URL": baseURL,
|
||||
"ARK_MODEL": model,
|
||||
} {
|
||||
if value == "" {
|
||||
t.Fatalf("%s is required when DWS_AGENT_SELECTION_LIVE=1", name)
|
||||
}
|
||||
}
|
||||
if err := validateManualAgentSelectionLiveBaseURL(baseURL, os.Getenv("DWS_AGENT_SELECTION_ALLOWED_BASE_URLS")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fixture, hints := manualAgentSelectionLiveFixture(t)
|
||||
cases := selectManualAgentSelectionLiveCases(t, fixture.Cases)
|
||||
for _, batch := range batchManualAgentSelectionLiveCases(cases, manualAgentSelectionLiveBatchSize) {
|
||||
productID := batch[0].ProductID
|
||||
t.Run(productID+"/"+sanitizeManualAgentSelectionLiveTestID(batch[0].ID), func(t *testing.T) {
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
results := callManualAgentSelectionLiveModel(t, baseURL, apiKey, model, input)
|
||||
assertManualAgentSelectionLiveResults(t, batch, results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildManualAgentSelectionLiveInput(batch []cli.ManualAgentSelectionCase, hints cli.ManualAgentHintSet) manualAgentSelectionLiveInput {
|
||||
input := manualAgentSelectionLiveInput{Cases: make([]manualAgentSelectionLiveCase, 0, len(batch))}
|
||||
if len(batch) == 0 {
|
||||
return input
|
||||
}
|
||||
for _, selectionCase := range batch {
|
||||
input.Cases = append(input.Cases, manualAgentSelectionLiveCase{
|
||||
ID: selectionCase.ID,
|
||||
Scenario: selectionCase.Scenario,
|
||||
})
|
||||
}
|
||||
input.Candidates = make([]manualAgentSelectionLiveCandidate, 0, len(batch[0].CandidateCanonicals))
|
||||
for _, canonical := range batch[0].CandidateCanonicals {
|
||||
hint := hints.Tools[canonical]
|
||||
input.Candidates = append(input.Candidates, manualAgentSelectionLiveCandidate{
|
||||
CanonicalPath: canonical,
|
||||
AgentSummary: hint.AgentSummary,
|
||||
UseWhen: hint.UseWhen,
|
||||
AvoidWhen: hint.AvoidWhen,
|
||||
})
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func manualAgentSelectionLiveFixture(t testing.TB) (cli.ManualAgentSelectionFixture, cli.ManualAgentHintSet) {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
fixture, _, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
return fixture, hints
|
||||
}
|
||||
|
||||
func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSelectionCase) []cli.ManualAgentSelectionCase {
|
||||
t.Helper()
|
||||
if raw := strings.TrimSpace(os.Getenv("DWS_AGENT_SELECTION_CASES")); raw != "" {
|
||||
selected := map[string]bool{}
|
||||
for _, id := range strings.Split(raw, ",") {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
selected[id] = true
|
||||
}
|
||||
}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0, len(selected))
|
||||
for _, selectionCase := range cases {
|
||||
if selected[selectionCase.ID] {
|
||||
result = append(result, selectionCase)
|
||||
delete(selected, selectionCase.ID)
|
||||
}
|
||||
}
|
||||
if len(selected) != 0 {
|
||||
missing := make([]string, 0, len(selected))
|
||||
for id := range selected {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
sort.Strings(missing)
|
||||
t.Fatalf("DWS_AGENT_SELECTION_CASES contains unknown case IDs: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return result
|
||||
}
|
||||
if os.Getenv("DWS_AGENT_SELECTION_FULL") == "1" {
|
||||
return append([]cli.ManualAgentSelectionCase(nil), cases...)
|
||||
}
|
||||
|
||||
// Smoke mode exercises one positive and one negative scenario per product.
|
||||
seenPositive := map[string]bool{}
|
||||
seenNegative := map[string]bool{}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if selectionCase.ExpectedCanonical != "" && !seenPositive[selectionCase.ProductID] {
|
||||
seenPositive[selectionCase.ProductID] = true
|
||||
result = append(result, selectionCase)
|
||||
}
|
||||
if selectionCase.ForbiddenCanonical != "" && !seenNegative[selectionCase.ProductID] {
|
||||
seenNegative[selectionCase.ProductID] = true
|
||||
result = append(result, selectionCase)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, batchSize int) [][]cli.ManualAgentSelectionCase {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
grouped := map[string][]cli.ManualAgentSelectionCase{}
|
||||
products := make([]string, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if _, ok := grouped[selectionCase.ProductID]; !ok {
|
||||
products = append(products, selectionCase.ProductID)
|
||||
}
|
||||
grouped[selectionCase.ProductID] = append(grouped[selectionCase.ProductID], selectionCase)
|
||||
}
|
||||
sort.Strings(products)
|
||||
result := make([][]cli.ManualAgentSelectionCase, 0)
|
||||
for _, productID := range products {
|
||||
productCases := grouped[productID]
|
||||
for start := 0; start < len(productCases); start += batchSize {
|
||||
end := start + batchSize
|
||||
if end > len(productCases) {
|
||||
end = len(productCases)
|
||||
}
|
||||
result = append(result, append([]cli.ManualAgentSelectionCase(nil), productCases[start:end]...))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model string, input manualAgentSelectionLiveInput) []manualAgentSelectionLiveResult {
|
||||
t.Helper()
|
||||
body, err := marshalManualAgentSelectionLiveRequest(baseURL, model, input)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal live selection request: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build live selection request: %v", err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) > 0 && (request.URL.Scheme != via[0].URL.Scheme || !strings.EqualFold(request.URL.Host, via[0].URL.Host)) {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("live selection model request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
detail, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
t.Fatalf("live selection model status %s: %s", response.Status, strings.TrimSpace(string(detail)))
|
||||
}
|
||||
var envelope struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 2<<20)).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode live selection response envelope: %v", err)
|
||||
}
|
||||
if len(envelope.Choices) == 0 || strings.TrimSpace(envelope.Choices[0].Message.Content) == "" {
|
||||
t.Fatal("live selection response has no model content")
|
||||
}
|
||||
var selection manualAgentSelectionLiveResponse
|
||||
decoder := json.NewDecoder(strings.NewReader(envelope.Choices[0].Message.Content))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&selection); err != nil {
|
||||
t.Fatalf("decode live selection model JSON: %v; content=%s", err, envelope.Choices[0].Message.Content)
|
||||
}
|
||||
return selection.Results
|
||||
}
|
||||
|
||||
func marshalManualAgentSelectionLiveRequest(baseURL, model string, input manualAgentSelectionLiveInput) ([]byte, error) {
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal live selection input: %w", err)
|
||||
}
|
||||
requestBody := map[string]any{
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"max_tokens": 4096,
|
||||
"messages": []map[string]string{
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You evaluate DWS Agent command selection. For each case, interpret the natural-language scenario and choose exactly one canonical_path from candidates, or the literal string none when no candidate is appropriate. Return only JSON as {\"results\":[{\"id\":\"case id\",\"canonical_path\":\"candidate or none\"}]}. Return every case ID exactly once. Do not execute commands.",
|
||||
},
|
||||
{"role": "user", "content": string(inputJSON)},
|
||||
},
|
||||
}
|
||||
// Ark plan endpoints do not consistently accept response_format. Other
|
||||
// OpenAI-compatible endpoints get the stricter JSON-object request.
|
||||
if !strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api/plan/v3") {
|
||||
requestBody["response_format"] = map[string]string{"type": "json_object"}
|
||||
}
|
||||
return json.Marshal(requestBody)
|
||||
}
|
||||
|
||||
func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgentSelectionCase, results []manualAgentSelectionLiveResult) {
|
||||
t.Helper()
|
||||
byID := make(map[string]manualAgentSelectionLiveResult, len(results))
|
||||
for _, result := range results {
|
||||
if _, exists := byID[result.ID]; exists {
|
||||
t.Fatalf("live selection returned duplicate case ID %q", result.ID)
|
||||
}
|
||||
byID[result.ID] = result
|
||||
}
|
||||
for _, selectionCase := range cases {
|
||||
result, ok := byID[selectionCase.ID]
|
||||
if !ok {
|
||||
t.Errorf("live selection omitted case %q", selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
delete(byID, selectionCase.ID)
|
||||
selected := strings.TrimSpace(result.CanonicalPath)
|
||||
if selected == "" {
|
||||
t.Errorf("live selection returned empty canonical for %q", selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
if selected != "none" && !containsManualAgentSelectionCanonical(selectionCase.CandidateCanonicals, selected) {
|
||||
t.Errorf("live selection returned non-candidate %q for %q", selected, selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
if selectionCase.ExpectedCanonical != "" && selected != selectionCase.ExpectedCanonical {
|
||||
t.Errorf("live positive selection %q = %q, want %q; scenario=%q", selectionCase.ID, selected, selectionCase.ExpectedCanonical, selectionCase.Scenario)
|
||||
}
|
||||
if selectionCase.ForbiddenCanonical != "" && selected == selectionCase.ForbiddenCanonical {
|
||||
t.Errorf("live negative selection %q chose forbidden %q; scenario=%q", selectionCase.ID, selected, selectionCase.Scenario)
|
||||
}
|
||||
}
|
||||
if len(byID) != 0 {
|
||||
unexpected := make([]string, 0, len(byID))
|
||||
for id := range byID {
|
||||
unexpected = append(unexpected, id)
|
||||
}
|
||||
sort.Strings(unexpected)
|
||||
t.Errorf("live selection returned unexpected case IDs: %s", strings.Join(unexpected, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func validateManualAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||||
return fmt.Errorf("ARK_BASE_URL must be an absolute HTTP(S) API base without query or fragment")
|
||||
}
|
||||
if parsed.Scheme == "http" {
|
||||
if !manualAgentSelectionLoopbackHost(parsed.Hostname()) {
|
||||
return fmt.Errorf("ARK_BASE_URL may use plaintext HTTP only for a loopback test endpoint")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("ARK_BASE_URL must use HTTPS, except for a loopback HTTP test endpoint")
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
"https://ark.ap-southeast.bytepluses.com/api/v3": true,
|
||||
"https://ark.cn-beijing.volces.com/api/plan/v3": true,
|
||||
}
|
||||
for _, candidate := range strings.Split(extraAllowed, ",") {
|
||||
candidate = strings.TrimRight(strings.TrimSpace(candidate), "/")
|
||||
if candidate != "" {
|
||||
allowed[candidate] = true
|
||||
}
|
||||
}
|
||||
normalized := strings.TrimRight(raw, "/")
|
||||
if !allowed[normalized] {
|
||||
return fmt.Errorf("ARK_BASE_URL %q is not allowlisted; use a built-in Ark base or add the exact HTTPS base to DWS_AGENT_SELECTION_ALLOWED_BASE_URLS", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func manualAgentSelectionLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func containsManualAgentSelectionCanonical(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeManualAgentSelectionLiveTestID(value string) string {
|
||||
value = strings.ReplaceAll(value, ".", "_")
|
||||
value = strings.ReplaceAll(value, "/", "_")
|
||||
return value
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveResultContract(t *testing.T) {
|
||||
cases := []cli.ManualAgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
t.Run("accepts exact positive and negative choices", func(t *testing.T) {
|
||||
assertManualAgentSelectionLiveResults(t, cases, []manualAgentSelectionLiveResult{
|
||||
{ID: cases[0].ID, CanonicalPath: "sample.search"},
|
||||
{ID: cases[1].ID, CanonicalPath: "sample.create"},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t *testing.T) {
|
||||
batch := []cli.ManualAgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
hints := cli.ManualAgentHintSet{Tools: map[string]cli.ManualAgentToolHint{
|
||||
"sample.create": {AgentSummary: "Create an item", UseWhen: []string{"create"}, AvoidWhen: []string{"find"}},
|
||||
"sample.search": {AgentSummary: "Search items", UseWhen: []string{"find"}, AvoidWhen: []string{"create"}},
|
||||
}}
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
data, err := marshalManualAgentSelectionLiveRequest("https://ark.cn-beijing.volces.com/api/plan/v3", "fixed-model", input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbiddenKey := range []string{"expected_canonical", "forbidden_canonical", "candidate_canonicals"} {
|
||||
if strings.Contains(string(data), forbiddenKey) {
|
||||
t.Fatalf("live model payload leaks local assertion field %q: %s", forbiddenKey, data)
|
||||
}
|
||||
}
|
||||
if len(input.Candidates) != 2 || len(input.Cases) != 2 {
|
||||
t.Fatalf("live model input = %+v", input)
|
||||
}
|
||||
if count := strings.Count(string(data), `\"candidates\"`); count != 1 {
|
||||
t.Fatalf("live request contains candidate table %d times, want once: %s", count, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
extraAllowed string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "built-in Ark", baseURL: "https://ark.cn-beijing.volces.com/api/plan/v3"},
|
||||
{name: "loopback localhost", baseURL: "http://localhost:8080/v1"},
|
||||
{name: "loopback IPv4", baseURL: "http://127.0.0.1:8080/v1"},
|
||||
{name: "loopback IPv6", baseURL: "http://[::1]:8080/v1"},
|
||||
{name: "allowlisted HTTPS extension", baseURL: "https://models.example.test/v1", extraAllowed: "https://models.example.test/v1"},
|
||||
{name: "plaintext remote", baseURL: "http://models.example.test/v1", wantErr: "only for a loopback"},
|
||||
{name: "HTTPS not allowlisted", baseURL: "https://models.example.test/v1", wantErr: "not allowlisted"},
|
||||
{name: "allowlist path mismatch", baseURL: "https://models.example.test/v2", extraAllowed: "https://models.example.test/v1", wantErr: "not allowlisted"},
|
||||
{name: "URL credentials", baseURL: "https://token@ark.cn-beijing.volces.com/api/plan/v3", wantErr: "absolute HTTP(S)"},
|
||||
{name: "query", baseURL: "https://ark.cn-beijing.volces.com/api/plan/v3?q=1", wantErr: "absolute HTTP(S)"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateManualAgentSelectionLiveBaseURL(test.baseURL, test.extraAllowed)
|
||||
if test.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate base URL: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestFinalSchemaToolsHaveExecutableBaseCommands is the final Schema-to-Cobra
|
||||
// delivery gate. It starts from the reviewed CommandRegistry and live Cobra
|
||||
// tree, then verifies the complete final Schema projection against the bound
|
||||
// commands. The Catalog is observed only as a delivery output; it is never
|
||||
// used to discover or synthesize a command identity.
|
||||
func TestFinalSchemaToolsHaveExecutableBaseCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("bind EffectiveCommandRegistry to live Cobra tree: %v", err)
|
||||
}
|
||||
|
||||
publicCanonicals := make([]string, 0, len(bound.Commands))
|
||||
for _, command := range bound.Commands {
|
||||
if command.Visibility == cli.SchemaVisibilityPublic {
|
||||
publicCanonicals = append(publicCanonicals, command.CanonicalPath)
|
||||
}
|
||||
}
|
||||
sort.Strings(publicCanonicals)
|
||||
finalCanonicals := make([]string, 0, len(snapshot.Tools))
|
||||
for canonical := range snapshot.Tools {
|
||||
finalCanonicals = append(finalCanonicals, canonical)
|
||||
}
|
||||
sort.Strings(finalCanonicals)
|
||||
if diff := schemaBaseCommandSetDiff(publicCanonicals, finalCanonicals); diff != "" {
|
||||
t.Fatalf("final Schema tool set differs from public BoundCommandRegistry: %s", diff)
|
||||
}
|
||||
|
||||
for _, canonical := range finalCanonicals {
|
||||
canonical := canonical
|
||||
t.Run(canonical, func(t *testing.T) {
|
||||
tool := snapshot.Tools[canonical]
|
||||
command, ok := bound.ByCanonical[canonical]
|
||||
if !ok {
|
||||
t.Fatalf("final Schema tool has no BoundCommand")
|
||||
}
|
||||
if command.Visibility != cli.SchemaVisibilityPublic {
|
||||
t.Fatalf("final Schema tool binds non-public command visibility %q", command.Visibility)
|
||||
}
|
||||
if got := schemaBaseCommandString(tool["canonical_path"]); got != canonical {
|
||||
t.Fatalf("final canonical_path = %q, want %q", got, canonical)
|
||||
}
|
||||
if got := schemaBaseCommandString(tool["primary_cli_path"]); got != command.PrimaryCLIPath {
|
||||
t.Fatalf("final primary_cli_path = %q, want bound primary %q", got, command.PrimaryCLIPath)
|
||||
}
|
||||
if got := schemaBaseCommandString(tool["cli_path"]); got != command.PrimaryCLIPath {
|
||||
t.Fatalf("final canonical view cli_path = %q, want bound primary %q", got, command.PrimaryCLIPath)
|
||||
}
|
||||
|
||||
primaryMatch, err := resolveSchemaBaseCommandPath(root, command.PrimaryCLIPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve primary path exactly: %v", err)
|
||||
}
|
||||
if primaryMatch.command == nil {
|
||||
t.Fatalf("bound primary path %q does not exist in live Cobra tree", command.PrimaryCLIPath)
|
||||
}
|
||||
if primaryMatch.usedAlias {
|
||||
t.Fatalf("bound primary path %q resolves through Cobra Aliases", command.PrimaryCLIPath)
|
||||
}
|
||||
if primaryMatch.command != command.PrimaryCommand {
|
||||
t.Fatalf("bound primary pointer differs from exact live Cobra path %q", command.PrimaryCLIPath)
|
||||
}
|
||||
assertRunnableSchemaBaseCommand(t, command.PrimaryCommand, command.PrimaryCLIPath)
|
||||
|
||||
// Cobra dispatches a parsed --help flag to Help without invoking the
|
||||
// command's Run/RunE or any business interface. Calling Help directly
|
||||
// therefore exercises the same renderer without network side effects.
|
||||
var help bytes.Buffer
|
||||
command.PrimaryCommand.SetOut(&help)
|
||||
command.PrimaryCommand.SetErr(&help)
|
||||
if err := command.PrimaryCommand.Help(); err != nil {
|
||||
t.Fatalf("render %q --help: %v", command.PrimaryCLIPath, err)
|
||||
}
|
||||
if strings.TrimSpace(help.String()) == "" {
|
||||
t.Fatalf("%q --help rendered an empty document", command.PrimaryCLIPath)
|
||||
}
|
||||
|
||||
wantAliases := append([]string(nil), command.Aliases...)
|
||||
gotAliases := schemaBaseCommandStringSlice(tool["aliases"])
|
||||
sort.Strings(wantAliases)
|
||||
sort.Strings(gotAliases)
|
||||
if diff := schemaBaseCommandSetDiff(wantAliases, gotAliases); diff != "" {
|
||||
t.Fatalf("final aliases differ from BoundCommand: %s", diff)
|
||||
}
|
||||
|
||||
boundAliases := make(map[string]cli.BoundAlias, len(command.AliasCommands))
|
||||
for _, alias := range command.AliasCommands {
|
||||
if _, duplicate := boundAliases[alias.Path]; duplicate {
|
||||
t.Fatalf("BoundCommand has duplicate alias %q", alias.Path)
|
||||
}
|
||||
boundAliases[alias.Path] = alias
|
||||
}
|
||||
for _, aliasPath := range wantAliases {
|
||||
alias, ok := boundAliases[aliasPath]
|
||||
if !ok {
|
||||
t.Fatalf("registry alias %q has no BoundAlias", aliasPath)
|
||||
}
|
||||
aliasMatch, err := resolveSchemaBaseCommandPath(root, aliasPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve alias %q exactly: %v", aliasPath, err)
|
||||
}
|
||||
if aliasMatch.command == nil {
|
||||
t.Fatalf("bound alias %q does not exist in live Cobra tree", aliasPath)
|
||||
}
|
||||
if aliasMatch.command != alias.Command {
|
||||
t.Fatalf("BoundAlias pointer differs from exact live Cobra path %q", aliasPath)
|
||||
}
|
||||
assertRunnableSchemaBaseCommand(t, alias.Command, aliasPath)
|
||||
switch alias.Kind {
|
||||
case cli.AliasKindCobraAlias:
|
||||
if !aliasMatch.usedAlias || alias.Command != command.PrimaryCommand {
|
||||
t.Fatalf("Cobra alias %q must resolve through Aliases to the primary command pointer", aliasPath)
|
||||
}
|
||||
case cli.AliasKindCompatibilityLeaf:
|
||||
if aliasMatch.usedAlias || alias.Command == command.PrimaryCommand {
|
||||
t.Fatalf("compatibility alias %q must be a separate exact-name Cobra leaf", aliasPath)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("alias %q has unknown binding kind %q", aliasPath, alias.Kind)
|
||||
}
|
||||
if indexed, ok := bound.ByCLIPath[aliasPath]; !ok || indexed.CanonicalPath != canonical {
|
||||
t.Fatalf("BoundCommandRegistry path index %q does not resolve to %q", aliasPath, canonical)
|
||||
}
|
||||
}
|
||||
if len(boundAliases) != len(wantAliases) {
|
||||
t.Fatalf("BoundCommand exposes %d alias bindings for %d reviewed aliases", len(boundAliases), len(wantAliases))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Logf("validated %d final Schema tools and their executable base commands", len(finalCanonicals))
|
||||
}
|
||||
|
||||
func assertRunnableSchemaBaseCommand(t *testing.T, command *cobra.Command, path string) {
|
||||
t.Helper()
|
||||
if command == nil || !command.Runnable() || command.HasSubCommands() {
|
||||
t.Fatalf("Schema path %q does not bind a runnable Cobra leaf", path)
|
||||
}
|
||||
}
|
||||
|
||||
type schemaBaseCommandPathMatch struct {
|
||||
command *cobra.Command
|
||||
usedAlias bool
|
||||
}
|
||||
|
||||
// resolveSchemaBaseCommandPath independently resolves exact Cobra names and
|
||||
// aliases for the delivery contract test. Like the production binder, it does
|
||||
// not accept Cobra prefix matching or suggestions.
|
||||
func resolveSchemaBaseCommandPath(root *cobra.Command, rawPath string) (schemaBaseCommandPathMatch, error) {
|
||||
parts := strings.Fields(strings.TrimSpace(rawPath))
|
||||
if len(parts) > 0 && root != nil && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if root == nil || len(parts) == 0 {
|
||||
return schemaBaseCommandPathMatch{}, nil
|
||||
}
|
||||
|
||||
current := root
|
||||
usedAlias := false
|
||||
for _, part := range parts {
|
||||
exact := schemaBaseCommandChildrenNamed(current, part, false)
|
||||
if len(exact) > 1 {
|
||||
return schemaBaseCommandPathMatch{}, fmt.Errorf("command segment %q is ambiguous", part)
|
||||
}
|
||||
if len(exact) == 1 {
|
||||
current = exact[0]
|
||||
continue
|
||||
}
|
||||
aliases := schemaBaseCommandChildrenNamed(current, part, true)
|
||||
if len(aliases) > 1 {
|
||||
return schemaBaseCommandPathMatch{}, fmt.Errorf("alias segment %q is ambiguous", part)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return schemaBaseCommandPathMatch{}, nil
|
||||
}
|
||||
current = aliases[0]
|
||||
usedAlias = true
|
||||
}
|
||||
return schemaBaseCommandPathMatch{command: current, usedAlias: usedAlias}, nil
|
||||
}
|
||||
|
||||
func schemaBaseCommandChildrenNamed(parent *cobra.Command, name string, aliases bool) []*cobra.Command {
|
||||
var matches []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
matched := child.Name() == name
|
||||
if aliases {
|
||||
matched = false
|
||||
for _, alias := range child.Aliases {
|
||||
if alias == name {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
seen := false
|
||||
for _, existing := range matches {
|
||||
if existing == child {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
matches = append(matches, child)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func schemaBaseCommandString(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func schemaBaseCommandStringSlice(value any) []string {
|
||||
var values []string
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range values {
|
||||
values[index] = strings.TrimSpace(values[index])
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func schemaBaseCommandSetDiff(want, got []string) string {
|
||||
wantSet := make(map[string]bool, len(want))
|
||||
gotSet := make(map[string]bool, len(got))
|
||||
for _, value := range want {
|
||||
wantSet[value] = true
|
||||
}
|
||||
for _, value := range got {
|
||||
gotSet[value] = true
|
||||
}
|
||||
var missing, extra []string
|
||||
for value := range wantSet {
|
||||
if !gotSet[value] {
|
||||
missing = append(missing, value)
|
||||
}
|
||||
}
|
||||
for value := range gotSet {
|
||||
if !wantSet[value] {
|
||||
extra = append(extra, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
if len(missing) == 0 && len(extra) == 0 && len(want) == len(got) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("missing=%v extra=%v want_count=%d got_count=%d", missing, extra, len(want), len(got))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := NewRootCommand()
|
||||
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := cli.RuntimeSchemaCompleteness(root, exclusions)
|
||||
if len(report.Missing) > 0 || len(report.InvalidExclusions) > 0 || len(report.StaleExclusions) > 0 {
|
||||
t.Fatalf("runtime schema completeness: missing=%v invalid=%v stale=%v", report.Missing, report.InvalidExclusions, report.StaleExclusions)
|
||||
}
|
||||
if !containsSchemaPath(report.Covered, "chat category create-smart") {
|
||||
t.Fatal("chat category create-smart is not covered by runtime Schema")
|
||||
}
|
||||
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
|
||||
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
|
||||
}
|
||||
}
|
||||
|
||||
func containsSchemaPath(paths []string, want string) bool {
|
||||
for _, path := range paths {
|
||||
if path == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var (
|
||||
fullSchemaSnapshotOnce sync.Once
|
||||
fullSchemaSnapshot cli.SchemaCatalogSnapshot
|
||||
fullSchemaSnapshotErr error
|
||||
)
|
||||
|
||||
// fullSchemaSnapshotForTest runs the complete source-to-delivery invariant
|
||||
// once per test binary. The returned snapshot is a shared read-only fixture;
|
||||
// callers still build their own Cobra root when they need executable command
|
||||
// pointers. This preserves every full-Catalog assertion without paying the
|
||||
// multi-gigabyte generation/validation cost three times under -race.
|
||||
func fullSchemaSnapshotForTest(t testing.TB) cli.SchemaCatalogSnapshot {
|
||||
t.Helper()
|
||||
fullSchemaSnapshotOnce.Do(func() {
|
||||
resolved, err := cli.ResolveSchemaBuild(NewRootCommand())
|
||||
if err != nil {
|
||||
fullSchemaSnapshotErr = err
|
||||
return
|
||||
}
|
||||
fullSchemaSnapshot, fullSchemaSnapshotErr = cli.BuildSchemaCatalogSnapshot(resolved, cli.SchemaCatalogBuildOptions{})
|
||||
})
|
||||
if fullSchemaSnapshotErr != nil {
|
||||
t.Fatalf("build shared final Schema snapshot: %v", fullSchemaSnapshotErr)
|
||||
}
|
||||
return fullSchemaSnapshot
|
||||
}
|
||||
|
||||
func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := make(map[string]bool)
|
||||
for _, command := range effective.Commands {
|
||||
if command.Visibility == cli.SchemaVisibilityPublic {
|
||||
expected[command.CanonicalPath] = true
|
||||
}
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
Tools []struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
} `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
}
|
||||
actual := make(map[string]bool)
|
||||
var duplicates []string
|
||||
for _, product := range payload.Products {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(tool.CanonicalPath)
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
}
|
||||
if actual[canonical] {
|
||||
duplicates = append(duplicates, canonical)
|
||||
}
|
||||
actual[canonical] = true
|
||||
}
|
||||
}
|
||||
if len(duplicates) > 0 {
|
||||
sort.Strings(duplicates)
|
||||
t.Fatalf("embedded schema --all contains duplicate canonicals: %v", duplicates)
|
||||
}
|
||||
|
||||
var missing, extra []string
|
||||
for canonical := range expected {
|
||||
if !actual[canonical] {
|
||||
missing = append(missing, canonical)
|
||||
}
|
||||
}
|
||||
for canonical := range actual {
|
||||
if !expected[canonical] {
|
||||
extra = append(extra, canonical)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
t.Fatalf("embedded Schema canonical set differs from EffectiveCommandRegistry: missing=%v extra=%v", missing, extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
bindings, err := cli.EmbeddedSchemaParameterBindings()
|
||||
if err != nil {
|
||||
t.Fatalf("EmbeddedSchemaParameterBindings() error = %v", err)
|
||||
}
|
||||
if len(snapshot.Tools) == 0 {
|
||||
t.Fatal("generated Schema Catalog contains no tools")
|
||||
}
|
||||
for canonicalPath := range bindings {
|
||||
if _, ok := snapshot.Tools[canonicalPath]; !ok {
|
||||
t.Errorf("parameter bindings reference canonical %q that is absent from the final generated Schema", canonicalPath)
|
||||
}
|
||||
}
|
||||
|
||||
for canonicalPath, definition := range snapshot.Tools {
|
||||
cliPath := schemaContractString(definition["primary_cli_path"])
|
||||
if cliPath == "" {
|
||||
cliPath = schemaContractString(definition["cli_path"])
|
||||
}
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil {
|
||||
for _, alias := range schemaContractStringSlice(definition["aliases"]) {
|
||||
if command = exactCommandForTest(root, alias); command != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if command == nil {
|
||||
t.Errorf("%s has no executable CLI path %q", canonicalPath, cliPath)
|
||||
continue
|
||||
}
|
||||
for parameterName, rawParameter := range schemaContractMap(definition["parameters"]) {
|
||||
flag := schemaContractCommandFlag(command, parameterName)
|
||||
if flag == nil {
|
||||
t.Errorf("%s maps parameter %q to missing flag on %q", canonicalPath, parameterName, command.CommandPath())
|
||||
continue
|
||||
}
|
||||
if got, want := schemaContractFlagDefault(flag), schemaContractString(rawParameter["default"]); want != got {
|
||||
t.Errorf("%s parameter %q default = %q, Cobra --help default = %q", canonicalPath, parameterName, want, got)
|
||||
}
|
||||
}
|
||||
for flagName, propertyName := range bindings[canonicalPath] {
|
||||
flag := schemaContractCommandFlag(command, flagName)
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Errorf("%s binding --%s references a missing or hidden public flag", canonicalPath, flagName)
|
||||
continue
|
||||
}
|
||||
parameter := schemaContractMap(definition["parameters"])[flagName]
|
||||
if parameter == nil || schemaContractString(parameter["property"]) != propertyName {
|
||||
t.Errorf("%s binding --%s -> %s is absent from generated Catalog", canonicalPath, flagName, propertyName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaParameterMetadataMapsToGeneratedCatalog(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
|
||||
for canonicalPath, metadata := range cli.RuntimeSchemaParameterMetadataDefinitions() {
|
||||
tool := snapshot.Tools[canonicalPath]
|
||||
if tool == nil {
|
||||
t.Errorf("parameter metadata references unknown tool %q", canonicalPath)
|
||||
continue
|
||||
}
|
||||
parameters, _ := tool["parameters"].(map[string]any)
|
||||
parameter := func(flagName string) map[string]any {
|
||||
value, _ := parameters[flagName].(map[string]any)
|
||||
if value == nil {
|
||||
t.Errorf("%s parameter metadata references unknown flag --%s", canonicalPath, flagName)
|
||||
}
|
||||
return value
|
||||
}
|
||||
for _, flagName := range metadata.Inherited {
|
||||
parameter(flagName)
|
||||
}
|
||||
for _, flagName := range metadata.Required {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "required", true)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.RequiredWhen {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "required_when", want)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.Formats {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "format", want)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.Examples {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "example", want)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.Enums {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "enum", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertRuntimeSchemaMetadataCandidate verifies the field-level resolver
|
||||
// contract without assuming which source wins. Typed runtime metadata must
|
||||
// remain visible as a candidate, resolution must select exactly one candidate,
|
||||
// and the final payload/envelope must agree with that selected candidate.
|
||||
func assertRuntimeSchemaMetadataCandidate(t testing.TB, canonicalPath, flagName string, parameter map[string]any, field string, typedValue any) {
|
||||
t.Helper()
|
||||
fieldProvenance, _ := parameter["field_provenance"].(map[string]any)
|
||||
provenance, _ := fieldProvenance[field].(map[string]any)
|
||||
if provenance == nil {
|
||||
t.Errorf("%s --%s %s has no final resolver provenance", canonicalPath, flagName, field)
|
||||
return
|
||||
}
|
||||
|
||||
foundTypedCandidate := false
|
||||
var selected map[string]any
|
||||
selectedCount := 0
|
||||
for _, candidate := range schemaContractObjectSlice(provenance["candidates"]) {
|
||||
if candidate["source"] == "typed_parameter_metadata" && schemaContractJSONEqual(candidate["value"], typedValue) {
|
||||
foundTypedCandidate = true
|
||||
}
|
||||
if isSelected, _ := candidate["selected"].(bool); isSelected {
|
||||
selected = candidate
|
||||
selectedCount++
|
||||
}
|
||||
}
|
||||
if !foundTypedCandidate {
|
||||
t.Errorf("%s --%s %s provenance has no typed_parameter_metadata candidate with value %#v", canonicalPath, flagName, field, typedValue)
|
||||
}
|
||||
if selectedCount != 1 {
|
||||
t.Errorf("%s --%s %s provenance selected candidates = %d, want 1", canonicalPath, flagName, field, selectedCount)
|
||||
return
|
||||
}
|
||||
if got, want := parameter[field], selected["value"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s --%s final %s = %#v, selected provenance value = %#v", canonicalPath, flagName, field, got, want)
|
||||
}
|
||||
if got, want := provenance["value"], selected["value"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s --%s %s provenance value = %#v, selected candidate value = %#v", canonicalPath, flagName, field, got, want)
|
||||
}
|
||||
if got, want := provenance["precedence"], selected["precedence"]; got != want {
|
||||
t.Errorf("%s --%s %s provenance precedence = %#v, selected candidate precedence = %#v", canonicalPath, flagName, field, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractJSONEqual(left, right any) bool {
|
||||
leftJSON, leftErr := json.Marshal(left)
|
||||
rightJSON, rightErr := json.Marshal(right)
|
||||
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
|
||||
}
|
||||
|
||||
func schemaContractObjectSlice(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if object, ok := item.(map[string]any); ok {
|
||||
out = append(out, object)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractFlagDefault(flag *pflag.Flag) string {
|
||||
if flag == nil {
|
||||
return ""
|
||||
}
|
||||
value := strings.TrimSpace(flag.DefValue)
|
||||
if value == "" || value == "0s" || value == "[]" || value == "{}" {
|
||||
return ""
|
||||
}
|
||||
switch flag.Value.Type() {
|
||||
case "bool":
|
||||
if value == "false" {
|
||||
return ""
|
||||
}
|
||||
case "int", "int8", "int16", "int32", "int64", "float32", "float64":
|
||||
if value == "0" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func schemaContractCommandFlag(command *cobra.Command, name string) *pflag.Flag {
|
||||
if command == nil {
|
||||
return nil
|
||||
}
|
||||
if flag := command.Flags().Lookup(name); flag != nil {
|
||||
return flag
|
||||
}
|
||||
for current := command; current != nil; current = current.Parent() {
|
||||
if flag := current.PersistentFlags().Lookup(name); flag != nil {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaContractMap(value any) map[string]map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]map[string]any:
|
||||
return typed
|
||||
case map[string]any:
|
||||
out := make(map[string]map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
if object, ok := item.(map[string]any); ok {
|
||||
out[key] = object
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractStringSlice(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// schemaContractPayloadForBoundCanonicals builds a small test fixture by
|
||||
// selecting already validated BoundCommand entries before Schema assembly.
|
||||
// Production generation deliberately has no post-assembly subset option: its
|
||||
// delivered set must always equal the public EffectiveCommandRegistry set.
|
||||
func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command, canonicals ...string) cli.SchemaSnapshotPayload {
|
||||
t.Helper()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
|
||||
t.Fatalf("apply manual Schema hints: %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build effective CommandRegistry: %v", err)
|
||||
}
|
||||
fixtureRegistry := cli.EffectiveCommandRegistry{Commands: make([]cli.CommandSpec, 0, len(canonicals))}
|
||||
for _, canonical := range canonicals {
|
||||
command, ok := effective.ByCanonical[canonical]
|
||||
if !ok {
|
||||
t.Fatalf("effective fixture has no canonical %s", canonical)
|
||||
}
|
||||
fixtureRegistry.Commands = append(fixtureRegistry.Commands, command)
|
||||
}
|
||||
fixture, err := cli.BindEffectiveCommandRegistry(root, fixtureRegistry)
|
||||
if err != nil {
|
||||
t.Fatalf("bind synthetic effective CommandRegistry: %v", err)
|
||||
}
|
||||
registry, err := cli.AssembleSchemaRegistryFromBound(fixture)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble synthetic bound Schema registry: %v", err)
|
||||
}
|
||||
payload, err := registry.ToSnapshotPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("render synthetic bound Schema registry: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"chat.send_personal_message",
|
||||
"chat.reply_personal_message",
|
||||
)
|
||||
|
||||
send, ok := snapshot.Tools["chat.send_personal_message"]
|
||||
if !ok || schemaContractString(send["primary_cli_path"]) != "chat message send" {
|
||||
t.Fatalf("send definition = %#v", send)
|
||||
}
|
||||
reply, ok := snapshot.Tools["chat.reply_personal_message"]
|
||||
if !ok || schemaContractString(reply["primary_cli_path"]) != "chat message reply" {
|
||||
t.Fatalf("reply definition = %#v", reply)
|
||||
}
|
||||
interfaceRef, _ := reply["interface_ref"].(map[string]any)
|
||||
if schemaContractString(interfaceRef["product_id"]) != "chat" || schemaContractString(interfaceRef["rpc_name"]) != "send_personal_message" {
|
||||
t.Fatalf("reply interface = %#v", interfaceRef)
|
||||
}
|
||||
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
|
||||
t.Fatal("downlined chat file upload must not be advertised in Schema")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, root, "calendar.remove_calendar_participant")
|
||||
tool := snapshot.Tools["calendar.remove_calendar_participant"]
|
||||
// Runtime does not gate this path today; Schema confirmation must follow metadata.runtime_gate.
|
||||
if got := tool["confirmation"]; got != "not_required" {
|
||||
t.Fatalf("calendar attendee delete confirmation = %#v, want not_required", got)
|
||||
}
|
||||
if got := tool["risk"]; got != "medium" {
|
||||
t.Fatalf("calendar attendee delete risk = %#v, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptingWritesRequireUserConfirmation(t *testing.T) {
|
||||
wantEffects := map[string]string{
|
||||
"attendance.class_create": "write",
|
||||
"attendance.class_update": "write",
|
||||
"doc.delete_comment": "write",
|
||||
"doc.version_revert": "write",
|
||||
"drive.publish_set": "write",
|
||||
"drive.publish_unset": "write",
|
||||
"sheet.chart_delete": "write",
|
||||
"sheet.delete_pivot_table": "write",
|
||||
}
|
||||
wantRisks := map[string]string{
|
||||
"attendance.class_create": "medium",
|
||||
"attendance.class_update": "medium",
|
||||
"doc.delete_comment": "medium",
|
||||
"doc.version_revert": "medium",
|
||||
"drive.publish_set": "medium",
|
||||
"drive.publish_unset": "medium",
|
||||
"sheet.chart_delete": "medium",
|
||||
"sheet.delete_pivot_table": "medium",
|
||||
}
|
||||
wantSources := map[string]string{
|
||||
"attendance.class_create": "internal/cli/schema_hints/metadata/attendance.json",
|
||||
"attendance.class_update": "internal/cli/schema_hints/metadata/attendance.json",
|
||||
"doc.delete_comment": "internal/cli/schema_hints/metadata/doc.json",
|
||||
"doc.version_revert": "internal/cli/schema_hints/metadata/doc.json",
|
||||
"drive.publish_set": "internal/cli/schema_hints/metadata/drive.json",
|
||||
"drive.publish_unset": "internal/cli/schema_hints/metadata/drive.json",
|
||||
"sheet.chart_delete": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"sheet.delete_pivot_table": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
}
|
||||
canonicals := make([]string, 0, len(wantEffects))
|
||||
for canonical := range wantEffects {
|
||||
canonicals = append(canonicals, canonical)
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
tool := snapshot.Tools[canonical]
|
||||
if got := tool["effect"]; got != wantEffects[canonical] {
|
||||
t.Errorf("%s effect = %#v, want %s", canonical, got, wantEffects[canonical])
|
||||
}
|
||||
if got := tool["risk"]; got != wantRisks[canonical] {
|
||||
t.Errorf("%s risk = %#v, want %s", canonical, got, wantRisks[canonical])
|
||||
}
|
||||
if got := tool["confirmation"]; got != "user_required" {
|
||||
t.Errorf("%s confirmation = %#v, want user_required", canonical, got)
|
||||
}
|
||||
provenance, _ := tool["field_provenance"].(map[string]any)
|
||||
for _, field := range []string{"effect", "risk", "confirmation"} {
|
||||
selected, _ := provenance[field].(map[string]any)
|
||||
if got := selected["source"]; got != wantSources[canonical] {
|
||||
t.Errorf("%s %s provenance source = %#v, want %s", canonical, field, got, wantSources[canonical])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMainCommandInterfaceConversionsReachFinalSchema(t *testing.T) {
|
||||
type conversion struct {
|
||||
canonical string
|
||||
flag string
|
||||
cliType string
|
||||
interfaceType string
|
||||
}
|
||||
wants := []conversion{
|
||||
{canonical: "chat.list_message_favorites", flag: "size", cliType: "integer", interfaceType: "string"},
|
||||
{canonical: "doc.update_comment", flag: "mention", cliType: "string", interfaceType: "array"},
|
||||
{canonical: "sheet.create_pivot_table", flag: "properties", cliType: "string", interfaceType: "object"},
|
||||
{canonical: "sheet.table_put", flag: "sheets", cliType: "string", interfaceType: "array"},
|
||||
{canonical: "sheet.update_pivot_table", flag: "properties", cliType: "string", interfaceType: "object"},
|
||||
}
|
||||
canonicals := make([]string, 0, len(wants))
|
||||
for _, want := range wants {
|
||||
canonicals = append(canonicals, want.canonical)
|
||||
}
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, want := range wants {
|
||||
tool := snapshot.Tools[want.canonical]
|
||||
parameters, _ := tool["parameters"].(map[string]any)
|
||||
parameter, _ := parameters[want.flag].(map[string]any)
|
||||
if got := schemaContractString(parameter["type"]); got != want.cliType {
|
||||
t.Errorf("%s --%s CLI type = %q, want %q", want.canonical, want.flag, got, want.cliType)
|
||||
}
|
||||
if got := schemaContractString(parameter["interface_type"]); got != want.interfaceType {
|
||||
t.Errorf("%s --%s interface_type = %q, want %q", want.canonical, want.flag, got, want.interfaceType)
|
||||
}
|
||||
}
|
||||
if got := snapshot.Tools["sheet.create_pivot_table"]["idempotency"]; got != "non_idempotent" {
|
||||
t.Errorf("sheet.create_pivot_table idempotency = %#v, want non_idempotent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultedPaginationSchemaFlagsAreOptional(t *testing.T) {
|
||||
wants := map[string][]string{
|
||||
"chat.search_messages_by_time_range": {"limit"},
|
||||
"oa.list_user_visible_process": {"cursor", "limit"},
|
||||
"report.get_received_report_list": {"cursor", "size"},
|
||||
"todo.get_user_todos_in_current_org": {"page"},
|
||||
}
|
||||
canonicals := make([]string, 0, len(wants)+1)
|
||||
for canonicalPath := range wants {
|
||||
canonicals = append(canonicals, canonicalPath)
|
||||
}
|
||||
canonicals = append(canonicals, "aitable.section_reorder")
|
||||
sort.Strings(canonicals)
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for canonicalPath, flags := range wants {
|
||||
parameters := schemaContractMap(snapshot.Tools[canonicalPath]["parameters"])
|
||||
for _, flagName := range flags {
|
||||
if parameters[flagName]["required"] == true {
|
||||
t.Errorf("%s --%s has a CLI default and must remain optional", canonicalPath, flagName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetIndex := schemaContractMap(snapshot.Tools["aitable.section_reorder"]["parameters"])["target-index"]
|
||||
if targetIndex["required"] != true {
|
||||
t.Error("aitable.section_reorder --target-index uses -1 as a sentinel and must remain required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATSchemaKeepsCLIContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, root, "pat.batch_grant")
|
||||
tool := payload.Tools["pat.batch_grant"]
|
||||
parameters, _ := tool["parameters"].(map[string]any)
|
||||
grantType, _ := parameters["grant-type"].(map[string]any)
|
||||
if grantType["default"] != "permanent" {
|
||||
t.Fatalf("grant-type default = %#v", grantType["default"])
|
||||
}
|
||||
positionals, _ := tool["positionals"].([]any)
|
||||
if len(positionals) != 1 {
|
||||
t.Fatalf("PAT positionals = %#v", tool["positionals"])
|
||||
}
|
||||
positional, _ := positionals[0].(map[string]any)
|
||||
if positional["name"] != "scope" || positional["variadic"] != true {
|
||||
t.Fatalf("PAT positionals = %#v", tool["positionals"])
|
||||
}
|
||||
}
|
||||
|
||||
func exactCommandForTest(root *cobra.Command, path string) *cobra.Command {
|
||||
parts := strings.Fields(strings.TrimSpace(path))
|
||||
if len(parts) > 0 && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
current := root
|
||||
for _, name := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == name {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
if current == root {
|
||||
return nil
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestFinalSchemaParametersMatchExecutableHelpFlags is the fast, in-process
|
||||
// Help <-> Schema parameter completeness gate. It deliberately starts from the
|
||||
// reviewed registry and its exact Cobra bindings, then compares every public
|
||||
// primary leaf with the final delivered ToolSpec projection. The binder has
|
||||
// already proved that reviewed compatibility leaves have the same executable
|
||||
// contract as their primary, so aliases do not create a second parameter
|
||||
// source here.
|
||||
func TestFinalSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, snapshot.Tools, "source-built final Schema")
|
||||
}
|
||||
|
||||
// TestEmbeddedSchemaParametersMatchExecutableHelpFlags runs the same exact-set
|
||||
// gate against the artifact that ships in the binary. Going through the real
|
||||
// schema --all command is intentional: a stale generated Catalog must fail
|
||||
// even when a fresh source-built snapshot would agree with Cobra Help.
|
||||
func TestEmbeddedSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, tools, "embedded schema --all")
|
||||
}
|
||||
|
||||
func boundSchemaCommandsForHelpFlagTest(t testing.TB, root *cobra.Command) cli.BoundCommandRegistry {
|
||||
t.Helper()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("bind EffectiveCommandRegistry to live Cobra tree: %v", err)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
func embeddedSchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) map[string]map[string]any {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
Tools []map[string]any `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
}
|
||||
tools := make(map[string]map[string]any)
|
||||
for _, product := range payload.Products {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(schemaContractString(tool["canonical_path"]))
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
}
|
||||
if _, exists := tools[canonical]; exists {
|
||||
t.Fatalf("embedded schema --all contains duplicate canonical %q", canonical)
|
||||
}
|
||||
tools[canonical] = tool
|
||||
}
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("embedded schema --all contains no tools")
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func assertSchemaParametersMatchExecutableHelpFlags(
|
||||
t testing.TB,
|
||||
bound cli.BoundCommandRegistry,
|
||||
tools map[string]map[string]any,
|
||||
source string,
|
||||
) {
|
||||
t.Helper()
|
||||
problems := schemaHelpFlagCompletenessProblems(bound, tools)
|
||||
checked := 0
|
||||
for _, command := range bound.Commands {
|
||||
if command.Visibility == cli.SchemaVisibilityPublic {
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("%s parameter surface differs from executable Cobra Help:\n%s", source, strings.Join(problems, "\n"))
|
||||
}
|
||||
t.Logf("validated Help-visible flags against %s parameters for %d public tools", source, checked)
|
||||
}
|
||||
|
||||
func TestSchemaHelpFlagCompletenessRejectsStaleCatalogFlags(t *testing.T) {
|
||||
leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("fresh", "", "new executable input")
|
||||
bound := cli.BoundCommandRegistry{Commands: []cli.BoundCommandSpec{{
|
||||
CommandSpec: cli.CommandSpec{
|
||||
CanonicalPath: "sample.run",
|
||||
PrimaryCLIPath: "sample run",
|
||||
Visibility: cli.SchemaVisibilityPublic,
|
||||
},
|
||||
PrimaryCommand: leaf,
|
||||
}}}
|
||||
tools := map[string]map[string]any{
|
||||
"sample.run": {
|
||||
"parameters": map[string]any{
|
||||
"stale": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
problems := schemaHelpFlagCompletenessProblems(bound, tools)
|
||||
joined := strings.Join(problems, "\n")
|
||||
if !strings.Contains(joined, `missing_in_schema=["fresh"]`) ||
|
||||
!strings.Contains(joined, `extra_in_schema=["stale"]`) {
|
||||
t.Fatalf("stale Catalog flag drift was not reported: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaHelpFlagCompletenessProblems(bound cli.BoundCommandRegistry, tools map[string]map[string]any) []string {
|
||||
var problems []string
|
||||
public := make(map[string]bool)
|
||||
checked := 0
|
||||
for _, command := range bound.Commands {
|
||||
if command.Visibility != cli.SchemaVisibilityPublic {
|
||||
continue
|
||||
}
|
||||
public[command.CanonicalPath] = true
|
||||
checked++
|
||||
tool, ok := tools[command.CanonicalPath]
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"canonical=%q path=%q missing final Schema tool",
|
||||
command.CanonicalPath,
|
||||
command.PrimaryCLIPath,
|
||||
))
|
||||
continue
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(
|
||||
command.CanonicalPath,
|
||||
command.PrimaryCLIPath,
|
||||
command.PrimaryCommand,
|
||||
tool,
|
||||
); problem != "" {
|
||||
problems = append(problems, problem)
|
||||
}
|
||||
}
|
||||
for canonical := range tools {
|
||||
if !public[canonical] {
|
||||
problems = append(problems, fmt.Sprintf("canonical=%q is an unexpected final Schema tool", canonical))
|
||||
}
|
||||
}
|
||||
if checked != len(tools) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"public BoundCommand count=%d final Schema tool count=%d",
|
||||
checked,
|
||||
len(tools),
|
||||
))
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
func TestSchemaHelpFlagCompletenessRejectsAncestorPersistentLeak(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
product := &cobra.Command{Use: "product"}
|
||||
product.PersistentFlags().String("leaked", "", "product-scoped option")
|
||||
leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("declared", "", "declared option")
|
||||
leaf.Flags().String("json", "", "Base JSON object payload for this tool invocation")
|
||||
leaf.Flags().String("hidden", "", "internal option")
|
||||
_ = leaf.Flags().MarkHidden("hidden")
|
||||
root.AddCommand(product)
|
||||
product.AddCommand(leaf)
|
||||
|
||||
tool := map[string]any{
|
||||
"parameters": map[string]any{
|
||||
"declared": map[string]any{"type": "string"},
|
||||
},
|
||||
}
|
||||
problem := schemaHelpFlagCompletenessProblem("product.run", "product run", leaf, tool)
|
||||
if !strings.Contains(problem, `missing_in_schema=["leaked"]`) {
|
||||
t.Fatalf("ancestor persistent leak was not reported: %s", problem)
|
||||
}
|
||||
if strings.Contains(problem, "format") || strings.Contains(problem, "json") || strings.Contains(problem, "hidden") {
|
||||
t.Fatalf("root controls and reviewed non-Schema flags must be excluded: %s", problem)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaHelpFlagCompletenessProblem(canonical, path string, command *cobra.Command, tool map[string]any) string {
|
||||
helpFlags := schemaHelpVisibleFlagNames(command)
|
||||
schemaFlags := make(map[string]bool)
|
||||
for name := range schemaContractMap(tool["parameters"]) {
|
||||
schemaFlags[name] = true
|
||||
}
|
||||
|
||||
missing := schemaFlagNameDifference(helpFlags, schemaFlags)
|
||||
extra := schemaFlagNameDifference(schemaFlags, helpFlags)
|
||||
if len(missing) == 0 && len(extra) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"canonical=%q path=%q missing_in_schema=%s extra_in_schema=%s",
|
||||
canonical,
|
||||
path,
|
||||
schemaQuotedFlagNames(missing),
|
||||
schemaQuotedFlagNames(extra),
|
||||
)
|
||||
}
|
||||
|
||||
// schemaHelpVisibleFlagNames models Cobra's leaf Help surface without rendering
|
||||
// text. Local flags and ancestor persistent flags are executable tool inputs.
|
||||
// Only the reviewed root execution controls are omitted; an unexpected new
|
||||
// root persistent flag must fail this gate instead of being silently treated as
|
||||
// process scaffolding.
|
||||
func schemaHelpVisibleFlagNames(command *cobra.Command) map[string]bool {
|
||||
visible := make(map[string]bool)
|
||||
if command == nil {
|
||||
return visible
|
||||
}
|
||||
|
||||
visit := func(flag *pflag.Flag, rootPersistent bool) {
|
||||
if flag == nil || flag.Hidden || flag.Name == "help" || schemaGenericPayloadEscapeHatch(flag) {
|
||||
return
|
||||
}
|
||||
if rootPersistent && schemaRootExecutionControl(flag.Name) {
|
||||
return
|
||||
}
|
||||
visible[flag.Name] = true
|
||||
}
|
||||
command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { visit(flag, false) })
|
||||
command.PersistentFlags().VisitAll(func(flag *pflag.Flag) { visit(flag, false) })
|
||||
root := command.Root()
|
||||
for parent := command.Parent(); parent != nil; parent = parent.Parent() {
|
||||
isRoot := parent == root
|
||||
parent.PersistentFlags().VisitAll(func(flag *pflag.Flag) { visit(flag, isRoot) })
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func schemaRootExecutionControl(name string) bool {
|
||||
switch name {
|
||||
case "client-id", "client-secret", "debug", "dry-run", "fields", "format", "jq", "mock",
|
||||
"output", "profile", "timeout", "token", "verbose", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func schemaGenericPayloadEscapeHatch(flag *pflag.Flag) bool {
|
||||
if flag == nil {
|
||||
return false
|
||||
}
|
||||
switch flag.Name {
|
||||
case "json":
|
||||
return strings.TrimSpace(flag.Usage) == "Base JSON object payload for this tool invocation"
|
||||
case "params":
|
||||
return strings.TrimSpace(flag.Usage) == "Additional JSON object payload merged after --json"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func schemaFlagNameDifference(left, right map[string]bool) []string {
|
||||
result := make([]string, 0)
|
||||
for name := range left {
|
||||
if !right[name] {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func schemaQuotedFlagNames(names []string) string {
|
||||
quoted := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", name))
|
||||
}
|
||||
return "[" + strings.Join(quoted, ",") + "]"
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
type interfaceCase struct {
|
||||
canonical string
|
||||
mode string
|
||||
reason string
|
||||
sourceSuffix string
|
||||
}
|
||||
tests := []interfaceCase{
|
||||
{
|
||||
canonical: "attendance.get_attendance_summary",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls attendance-wukong/get_user_attendance_summary, which is absent from the pinned MCP metadata snapshot; the incompatible attendance/get_attendance_summary contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/attendance.json",
|
||||
},
|
||||
{
|
||||
canonical: "drive.list_files",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --workspace between drive/list_files and doc/list_nodes, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/drive.json",
|
||||
},
|
||||
{
|
||||
canonical: "chat.search_groups",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls im/search_groups with a flat payload, while the pinned snapshot only contains the incompatible chat/search_groups_by_keyword contract.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/chat.json",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls sheet/get_cell_infos, which is absent from the pinned MCP metadata snapshot; the incompatible sheet/get_range contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
},
|
||||
{
|
||||
canonical: "wiki.list_wikiSpaces",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --type between wiki/list_wikiSpaces and drive/list_spaces, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/wiki.json",
|
||||
},
|
||||
{
|
||||
canonical: "event.consume",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
},
|
||||
{
|
||||
canonical: "event.status",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command reads the remote personal-event subscription control plane and combines it with local bus and consumer state; no single pinned RPC represents the result.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
},
|
||||
{
|
||||
canonical: "event.stop",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command deletes remote personal-event subscriptions, interrupts local consumers, updates local state, and may stop the local bus; no single pinned RPC represents the workflow.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
},
|
||||
}
|
||||
for _, canonical := range []string{
|
||||
"aitable.view_update_aggregate",
|
||||
"aitable.view_update_card",
|
||||
"aitable.view_update_field_widths",
|
||||
"aitable.view_update_timebar",
|
||||
} {
|
||||
tests = append(tests, interfaceCase{
|
||||
canonical: canonical,
|
||||
mode: "composite",
|
||||
reason: "The CLI performs an aitable/get_views preflight, locally transforms the requested configuration, and then calls aitable/update_view; the two-call workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/aitable.json",
|
||||
})
|
||||
}
|
||||
|
||||
canonicals := make([]string, 0, len(tests))
|
||||
for _, test := range tests {
|
||||
canonicals = append(canonicals, test.canonical)
|
||||
}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.canonical, func(t *testing.T) {
|
||||
tool := payload.Tools[test.canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != test.mode {
|
||||
t.Errorf("interface_mode = %q, want %q", got, test.mode)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("availability = %q, want available", got)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("interface_ref = %#v, want nil for %s wrapper", tool["interface_ref"], test.mode)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != test.reason {
|
||||
t.Errorf("interface_reason = %q, want %q", got, test.reason)
|
||||
}
|
||||
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
entry := provenance[field]
|
||||
if entry == nil {
|
||||
t.Errorf("missing %s provenance", field)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.HasSuffix(got, test.sourceSuffix) {
|
||||
t.Errorf("%s provenance source = %q, want suffix %q", field, got, test.sourceSuffix)
|
||||
}
|
||||
}
|
||||
if got := provenance["interface_ref"]["value"]; got != nil {
|
||||
t.Errorf("interface_ref provenance value = %#v, want explicit null", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewGetWrappersUsePinnedGetViewsInterface(t *testing.T) {
|
||||
canonicals := []string{
|
||||
"aitable.view_get_aggregate",
|
||||
"aitable.view_get_card",
|
||||
"aitable.view_get_field_widths",
|
||||
"aitable.view_get_fill_color_rule",
|
||||
"aitable.view_get_filter",
|
||||
"aitable.view_get_group",
|
||||
"aitable.view_get_sort",
|
||||
"aitable.view_get_timebar",
|
||||
"aitable.view_get_visible_fields",
|
||||
}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
tool := payload.Tools[canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "mcp" {
|
||||
t.Errorf("%s interface_mode = %q, want mcp", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability = %q, want available", canonical, got)
|
||||
}
|
||||
ref := schemaInterfaceObject(tool["interface_ref"])
|
||||
if product, rpc := schemaContractString(ref["product_id"]), schemaContractString(ref["rpc_name"]); product != "aitable" || rpc != "get_views" {
|
||||
t.Errorf("%s interface_ref = %q/%q, want aitable/get_views", canonical, product, rpc)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != "" {
|
||||
t.Errorf("%s interface_reason = %q, want empty for direct pinned interface", canonical, got)
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for flag, property := range map[string]string{
|
||||
"base-id": "baseId",
|
||||
"table-id": "tableId",
|
||||
"view-id": "viewIds",
|
||||
} {
|
||||
if got := schemaContractString(parameters[flag]["property"]); got != property {
|
||||
t.Errorf("%s --%s property = %q, want %q", canonical, flag, got, property)
|
||||
}
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref"} {
|
||||
entry := provenance[field]
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s %s precedence = %q, want reviewed_explicit", canonical, field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
|
||||
t.Errorf("%s %s source = %q, want reviewed interface disposition source", canonical, field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface(t *testing.T) {
|
||||
type hintFile struct {
|
||||
Source map[string]any `json:"source"`
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
load := func(path string) hintFile {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value hintFile
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
runtimeSurface := load("../cli/schema_hints/runtime-surface-completeness.json")
|
||||
legacyDispositionKeys := load("../cli/schema_hints/zz-interface-disposition-review.json").Tools
|
||||
dispositions := hintFile{Source: map[string]any{"reviewed": true}, Tools: map[string]map[string]any{}}
|
||||
for _, product := range []string{
|
||||
"attendance", "aitable", "chat", "drive", "event", "sheet", "wiki", "doc", "mail", "todo", "calendar", "conference", "contact", "dev", "devdoc", "ding", "live", "minutes", "oa", "pat", "report", "aisearch",
|
||||
} {
|
||||
path := "../cli/schema_hints/metadata/" + product + ".json"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
file := load(path)
|
||||
for canonical, hint := range file.Tools {
|
||||
if _, ok := legacyDispositionKeys[canonical]; !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := map[string]any{}
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
if value, exists := hint[field]; exists {
|
||||
trimmed[field] = value
|
||||
}
|
||||
}
|
||||
dispositions.Tools[canonical] = trimmed
|
||||
}
|
||||
}
|
||||
if dispositions.Source["reviewed"] != true {
|
||||
t.Fatalf("interface disposition source reviewed = %#v, want true", dispositions.Source["reviewed"])
|
||||
}
|
||||
for canonical, hint := range runtimeSurface.Tools {
|
||||
if hint["reviewed"] != false {
|
||||
t.Errorf("%s runtime surface reviewed = %#v, want false", canonical, hint["reviewed"])
|
||||
}
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
if _, exists := hint[field]; exists {
|
||||
t.Errorf("%s runtime surface still owns %s", canonical, field)
|
||||
}
|
||||
}
|
||||
if _, exists := dispositions.Tools[canonical]; !exists {
|
||||
t.Errorf("%s runtime surface has no reviewed interface disposition", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
allowedFields := map[string]bool{
|
||||
"interface_mode": true,
|
||||
"availability": true,
|
||||
"interface_ref": true,
|
||||
"interface_reason": true,
|
||||
}
|
||||
canonicals := make([]string, 0, len(dispositions.Tools))
|
||||
for canonical, hint := range dispositions.Tools {
|
||||
canonicals = append(canonicals, canonical)
|
||||
for field := range hint {
|
||||
if !allowedFields[field] {
|
||||
t.Errorf("%s interface-only source contains non-interface field %s", canonical, field)
|
||||
}
|
||||
}
|
||||
mode := schemaContractString(hint["interface_mode"])
|
||||
if mode == "local" {
|
||||
t.Errorf("%s remote interface review is incorrectly classified local", canonical)
|
||||
}
|
||||
if schemaContractString(hint["availability"]) != "available" {
|
||||
t.Errorf("%s reviewed disposition is not available", canonical)
|
||||
}
|
||||
switch mode {
|
||||
case "mcp":
|
||||
ref := schemaInterfaceObject(hint["interface_ref"])
|
||||
if schemaContractString(ref["product_id"]) == "" || schemaContractString(ref["rpc_name"]) == "" {
|
||||
t.Errorf("%s reviewed mcp disposition has no complete interface_ref", canonical)
|
||||
}
|
||||
case "composite":
|
||||
if hint["interface_ref"] != nil {
|
||||
t.Errorf("%s reviewed composite disposition advertises interface_ref %#v", canonical, hint["interface_ref"])
|
||||
}
|
||||
if schemaContractString(hint["interface_reason"]) == "" {
|
||||
t.Errorf("%s reviewed composite disposition has no reason", canonical)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s reviewed disposition mode = %q", canonical, mode)
|
||||
}
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
want := dispositions.Tools[canonical]
|
||||
tool := payload.Tools[canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != schemaContractString(want["interface_mode"]) {
|
||||
t.Errorf("%s final interface_mode = %q, want %q", canonical, got, want["interface_mode"])
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != schemaContractString(want["availability"]) {
|
||||
t.Errorf("%s final availability = %q, want %q", canonical, got, want["availability"])
|
||||
}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s final composite interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != schemaContractString(want["interface_reason"]) {
|
||||
t.Errorf("%s final interface_reason = %q, want %q", canonical, got, want["interface_reason"])
|
||||
}
|
||||
} else {
|
||||
gotRef := schemaInterfaceObject(tool["interface_ref"])
|
||||
wantRef := schemaInterfaceObject(want["interface_ref"])
|
||||
for _, field := range []string{"product_id", "rpc_name"} {
|
||||
if got := schemaContractString(gotRef[field]); got != schemaContractString(wantRef[field]) {
|
||||
t.Errorf("%s final interface_ref.%s = %q, want %q", canonical, field, got, wantRef[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
fields := []string{"interface_mode", "availability", "interface_ref"}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
fields = append(fields, "interface_reason")
|
||||
}
|
||||
for _, field := range fields {
|
||||
entry := provenance[field]
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s final %s precedence = %q, want reviewed_explicit", canonical, field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
|
||||
t.Errorf("%s final %s source = %q, want reviewed disposition source", canonical, field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func schemaInterfaceObject(value any) map[string]any {
|
||||
object, _ := value.(map[string]any)
|
||||
return object
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const schemaLazyStartupChildEnv = "DWS_SCHEMA_LAZY_STARTUP_CHILD"
|
||||
|
||||
// TestOrdinaryRootCommandsDoNotLoadSchemaMetadata uses a fresh process so its
|
||||
// counters describe package init, root construction, help, and version only;
|
||||
// unrelated Schema tests cannot have initialized the snapshots first.
|
||||
func TestOrdinaryRootCommandsDoNotLoadSchemaMetadata(t *testing.T) {
|
||||
if os.Getenv(schemaLazyStartupChildEnv) == "1" {
|
||||
assertSchemaMetadataNotLoaded(t, "package init")
|
||||
|
||||
root := NewRootCommand()
|
||||
assertSchemaMetadataNotLoaded(t, "NewRootCommand")
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("root --help: %v", err)
|
||||
}
|
||||
assertSchemaMetadataNotLoaded(t, "root --help")
|
||||
|
||||
root = NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("dws version: %v", err)
|
||||
}
|
||||
assertSchemaMetadataNotLoaded(t, "dws version")
|
||||
return
|
||||
}
|
||||
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestOrdinaryRootCommandsDoNotLoadSchemaMetadata$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), schemaLazyStartupChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("lazy startup child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaMetadataNotLoaded(t *testing.T, stage string) {
|
||||
t.Helper()
|
||||
if counts := cli.RuntimeSchemaMetadataLoadCounts(); counts != (cli.SchemaMetadataLoadCounts{}) {
|
||||
t.Fatalf("%s loaded Schema metadata: %#v", stage, counts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEventRegistryDeliversOneTypedSchemaPath(t *testing.T) {
|
||||
paths := map[string]string{
|
||||
"event.consume": "event consume",
|
||||
"event.list": "event list",
|
||||
"event.schema": "event schema",
|
||||
"event.status": "event status",
|
||||
"event.stop": "event stop",
|
||||
}
|
||||
wantModes := map[string]string{
|
||||
"event.consume": "composite",
|
||||
"event.list": "local",
|
||||
"event.schema": "local",
|
||||
"event.status": "composite",
|
||||
"event.stop": "composite",
|
||||
}
|
||||
canonicals := make([]string, 0, len(paths))
|
||||
for canonical := range paths {
|
||||
canonicals = append(canonicals, canonical)
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
if len(payload.Tools) != len(paths) {
|
||||
t.Fatalf("event fixture tools = %d, want %d", len(payload.Tools), len(paths))
|
||||
}
|
||||
if _, exists := payload.Tools["event._bus"]; exists {
|
||||
t.Fatal("hidden event _bus leaked into final Schema")
|
||||
}
|
||||
for canonical, primary := range paths {
|
||||
tool := payload.Tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("missing event tool %s", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != primary {
|
||||
t.Errorf("%s primary path = %q, want %q", canonical, got, primary)
|
||||
}
|
||||
if tool["interface_mode"] != wantModes[canonical] || tool["availability"] != "available" {
|
||||
t.Errorf("%s interface disposition = %v/%v, want %s/available", canonical, tool["interface_mode"], tool["availability"], wantModes[canonical])
|
||||
}
|
||||
if schemaContractString(tool["interface_reason"]) == "" {
|
||||
t.Errorf("%s has no reviewed interface reason", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
consume := payload.Tools["event.consume"]
|
||||
consumeParams := schemaContractMap(consume["parameters"])
|
||||
for _, hiddenOrGlobal := range []string{"as", "debug", "help", "profile", "timeout", "yes"} {
|
||||
if _, exists := consumeParams[hiddenOrGlobal]; exists {
|
||||
t.Errorf("event.consume exposes hidden/global flag --%s as a tool parameter", hiddenOrGlobal)
|
||||
}
|
||||
}
|
||||
for flag, wantType := range map[string]string{
|
||||
"dry-run": "boolean",
|
||||
"duration": "string",
|
||||
"event-types": "array",
|
||||
"max-events": "integer",
|
||||
} {
|
||||
if got := schemaContractString(consumeParams[flag]["type"]); got != wantType {
|
||||
t.Errorf("event.consume --%s type = %q, want %q", flag, got, wantType)
|
||||
}
|
||||
}
|
||||
if _, exists := consumeParams["duration"]["default"]; exists {
|
||||
t.Error("event.consume --duration leaked zero default 0s")
|
||||
}
|
||||
if consume["dry_run"] != nil {
|
||||
t.Errorf("event.consume declares an audited dry_run capability without a deterministic clean-environment preview: %#v", consume["dry_run"])
|
||||
}
|
||||
assertSchemaContractPositional(t, consume, "event_key", false)
|
||||
assertSchemaContractConstraintGroup(t, consume, "require_one_of", []string{"event_key", "subscribe-id"})
|
||||
|
||||
eventSchema := payload.Tools["event.schema"]
|
||||
assertSchemaContractPositional(t, eventSchema, "event_key", true)
|
||||
|
||||
stop := payload.Tools["event.stop"]
|
||||
if stop["effect"] != "destructive" || stop["risk"] != "high" || stop["confirmation"] != "user_required" {
|
||||
t.Errorf("event.stop safety = effect:%v risk:%v confirmation:%v", stop["effect"], stop["risk"], stop["confirmation"])
|
||||
}
|
||||
dryRun, _ := stop["dry_run"].(map[string]any)
|
||||
if dryRun["preview_kind"] != "request" {
|
||||
t.Errorf("event.stop dry_run = %#v, want request preview", stop["dry_run"])
|
||||
}
|
||||
assertSchemaContractPositional(t, stop, "subscribe_id", false)
|
||||
assertSchemaContractConstraintGroup(t, stop, "require_one_of", []string{"all", "subscribe_id"})
|
||||
assertSchemaContractConstraintGroup(t, stop, "mutually_exclusive", []string{"all", "subscribe_id"})
|
||||
}
|
||||
|
||||
func TestDevDocSearchBindingAndRequiredAlternativesReachFinalSchema(t *testing.T) {
|
||||
canonicals := []string{"dev.search_open_platform_docs_rag", "devdoc.search_open_platform_docs_rag"}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
tool := payload.Tools[canonical]
|
||||
query := schemaContractMap(tool["parameters"])["query"]
|
||||
if got := schemaContractString(query["property"]); got != "keyword" {
|
||||
t.Errorf("%s --query property = %q, want keyword", canonical, got)
|
||||
}
|
||||
if query["required"] != false {
|
||||
t.Errorf("%s --query required = %#v, want false because positional keyword is an alternative", canonical, query["required"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"query", "keyword"})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaContractPositional(t *testing.T, tool map[string]any, name string, required bool) {
|
||||
t.Helper()
|
||||
positionals, _ := tool["positionals"].([]any)
|
||||
for _, raw := range positionals {
|
||||
positional, _ := raw.(map[string]any)
|
||||
if positional["name"] == name {
|
||||
if positional["required"] != required {
|
||||
t.Errorf("positional %s required = %#v, want %v", name, positional["required"], required)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("missing positional %s in %#v", name, tool["positionals"])
|
||||
}
|
||||
|
||||
func assertSchemaContractConstraintGroup(t *testing.T, tool map[string]any, kind string, want []string) {
|
||||
t.Helper()
|
||||
constraints, _ := tool["constraints"].(map[string]any)
|
||||
groups, _ := constraints[kind].([]any)
|
||||
for _, rawGroup := range groups {
|
||||
rawValues, _ := rawGroup.([]any)
|
||||
values := make([]string, 0, len(rawValues))
|
||||
for _, value := range rawValues {
|
||||
if text, ok := value.(string); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
if reflect.DeepEqual(values, want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("constraint %s lacks group %v: %#v", kind, want, constraints[kind])
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
// Keep the folder-list wrapper and the free-form KQL search command as two
|
||||
// independent Agent contracts. The list command translates --folder-id into a
|
||||
// query before calling the same RPC, so treating it as a search alias loses a
|
||||
// real executable parameter surface.
|
||||
func TestMailListAndSearchRemainDistinctRegistryCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
list, listOK := effective.ByCanonical["mail.list_emails"]
|
||||
search, searchOK := effective.ByCanonical["mail.search_emails"]
|
||||
if !listOK || !searchOK {
|
||||
t.Fatalf("mail registry split missing: list=%t search=%t", listOK, searchOK)
|
||||
}
|
||||
if list.PrimaryCLIPath != "mail message list" || search.PrimaryCLIPath != "mail message search" {
|
||||
t.Fatalf("mail registry paths: list=%q search=%q", list.PrimaryCLIPath, search.PrimaryCLIPath)
|
||||
}
|
||||
if len(list.Aliases) != 0 || len(search.Aliases) != 0 {
|
||||
t.Fatalf("mail list/search must not alias each other: list=%v search=%v", list.Aliases, search.Aliases)
|
||||
}
|
||||
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
listCommand := bound.ByCanonical[list.CanonicalPath].PrimaryCommand
|
||||
searchCommand := bound.ByCanonical[search.CanonicalPath].PrimaryCommand
|
||||
if listCommand == nil || searchCommand == nil || listCommand == searchCommand {
|
||||
t.Fatalf("mail list/search Cobra bindings are not distinct: list=%p search=%p", listCommand, searchCommand)
|
||||
}
|
||||
if listCommand.Flags().Lookup("folder-id") == nil || listCommand.Flags().Lookup("query") != nil {
|
||||
t.Fatal("mail list Cobra surface must expose --folder-id and not --query")
|
||||
}
|
||||
if searchCommand.Flags().Lookup("query") == nil || searchCommand.Flags().Lookup("folder-id") != nil {
|
||||
t.Fatal("mail search Cobra surface must expose --query and not --folder-id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
func TestMinutesFixedScopeLeavesBindToDistinctRegistryTools(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("bind EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"minutes list all": "minutes.list_accessible_minutes",
|
||||
"minutes list mine": "minutes.list_by_keyword_and_time_range",
|
||||
"minutes list shared": "minutes.list_shared_minutes",
|
||||
}
|
||||
commands := map[any]bool{}
|
||||
canonicals := map[string]bool{}
|
||||
for path, canonical := range want {
|
||||
command, ok := bound.ByCLIPath[path]
|
||||
if !ok {
|
||||
t.Errorf("bound registry path %q is missing", path)
|
||||
continue
|
||||
}
|
||||
if command.CanonicalPath != canonical {
|
||||
t.Errorf("bound registry path %q canonical = %q, want %q", path, command.CanonicalPath, canonical)
|
||||
}
|
||||
if command.PrimaryCLIPath != path || len(command.Aliases) != 0 || len(command.AliasCommands) != 0 {
|
||||
t.Errorf("bound registry path %q retained alias navigation: primary=%q aliases=%v bound_aliases=%v", path, command.PrimaryCLIPath, command.Aliases, command.AliasCommands)
|
||||
}
|
||||
commands[command.PrimaryCommand] = true
|
||||
canonicals[command.CanonicalPath] = true
|
||||
}
|
||||
if len(commands) != len(want) || len(canonicals) != len(want) {
|
||||
t.Fatalf("minutes fixed-scope leaves collapsed: command pointers=%d canonicals=%d, want %d each", len(commands), len(canonicals), len(want))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSchemaReviewedInputMetadataUsesExecutableShapes(t *testing.T) {
|
||||
canonicals := []string{
|
||||
"aitable.field_update",
|
||||
"attendance.adjustment_search",
|
||||
"attendance.approve_list",
|
||||
"attendance.group_search",
|
||||
"attendance.overtime_search",
|
||||
"attendance.selfsetting_get",
|
||||
"attendance.vacation_update_type",
|
||||
"chat.list_owned_or_admin_groups",
|
||||
"sheet.batch_update",
|
||||
"sheet.chart_create",
|
||||
"sheet.chart_update",
|
||||
"sheet.create_pivot_table",
|
||||
"sheet.update_pivot_table",
|
||||
"sheet.range_batch_clear",
|
||||
"todo.add_todo_reminder",
|
||||
"todo.get_user_todos_in_current_org",
|
||||
}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
|
||||
optional := false
|
||||
parameterCases := []struct {
|
||||
canonical string
|
||||
flag string
|
||||
format string
|
||||
enum []string
|
||||
noEnum bool
|
||||
example string
|
||||
required *bool
|
||||
requiredWhen string
|
||||
jsonKind byte
|
||||
}{
|
||||
{canonical: "attendance.approve_list", flag: "start", format: "date"},
|
||||
{canonical: "attendance.approve_list", flag: "end", format: "date"},
|
||||
{canonical: "attendance.approve_list", flag: "types", noEnum: true, example: "overtime,leave"},
|
||||
{canonical: "attendance.adjustment_search", flag: "page", required: &optional},
|
||||
{canonical: "attendance.adjustment_search", flag: "limit", required: &optional},
|
||||
{canonical: "attendance.group_search", flag: "page", required: &optional},
|
||||
{canonical: "attendance.group_search", flag: "limit", required: &optional},
|
||||
{canonical: "attendance.overtime_search", flag: "page", required: &optional},
|
||||
{canonical: "attendance.overtime_search", flag: "limit", required: &optional},
|
||||
{canonical: "attendance.selfsetting_get", flag: "setting-scene", enum: []string{
|
||||
"checkRemind", "fastCheck", "checkResultNotify", "lackRemind",
|
||||
"personalAttendStatNotify", "bossAttendStatNotify",
|
||||
}},
|
||||
{canonical: "chat.list_owned_or_admin_groups", flag: "role", enum: []string{"OWNER", "ADMIN"}, required: &optional},
|
||||
{canonical: "chat.list_owned_or_admin_groups", flag: "limit", required: &optional},
|
||||
{canonical: "sheet.batch_update", flag: "operations", format: "json", jsonKind: '['},
|
||||
{canonical: "sheet.chart_create", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.chart_update", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.create_pivot_table", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.update_pivot_table", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.range_batch_clear", flag: "ranges", format: "json", jsonKind: '['},
|
||||
{canonical: "todo.add_todo_reminder", flag: "base-time", enum: []string{"dueTime", "customTime"}},
|
||||
{canonical: "todo.add_todo_reminder", flag: "due-date-offset", example: "-30", requiredWhen: "base-time is dueTime"},
|
||||
{canonical: "todo.add_todo_reminder", flag: "reminder-time-stamp", example: "2026-03-10T18:00:00+08:00", requiredWhen: "base-time is customTime"},
|
||||
{canonical: "todo.get_user_todos_in_current_org", flag: "role-types", noEnum: true, example: "creator,executor"},
|
||||
}
|
||||
for _, test := range parameterCases {
|
||||
t.Run(test.canonical+"/"+test.flag, func(t *testing.T) {
|
||||
tool := payload.Tools[test.canonical]
|
||||
parameter := schemaContractMap(tool["parameters"])[test.flag]
|
||||
if test.format != "" && parameter["format"] != test.format {
|
||||
t.Fatalf("format = %#v, want %q", parameter["format"], test.format)
|
||||
}
|
||||
if test.enum != nil {
|
||||
if got := schemaContractStringSlice(parameter["enum"]); !reflect.DeepEqual(got, test.enum) {
|
||||
t.Fatalf("enum = %#v, want %#v", got, test.enum)
|
||||
}
|
||||
}
|
||||
if test.noEnum {
|
||||
if got := schemaContractStringSlice(parameter["enum"]); len(got) != 0 {
|
||||
t.Fatalf("enum = %#v, want no scalar enum for a CSV parameter", got)
|
||||
}
|
||||
}
|
||||
if test.example != "" && parameter["example"] != test.example {
|
||||
t.Fatalf("example = %#v, want %q", parameter["example"], test.example)
|
||||
}
|
||||
if test.required != nil && parameter["required"] != *test.required {
|
||||
t.Fatalf("required = %#v, want %v", parameter["required"], *test.required)
|
||||
}
|
||||
if test.requiredWhen != "" && parameter["required_when"] != test.requiredWhen {
|
||||
t.Fatalf("required_when = %#v, want %q", parameter["required_when"], test.requiredWhen)
|
||||
}
|
||||
if test.jsonKind != 0 {
|
||||
example, ok := parameter["example"].(string)
|
||||
if !ok || example == "" {
|
||||
t.Fatalf("example = %#v, want non-empty JSON", parameter["example"])
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(example), &decoded); err != nil {
|
||||
t.Fatalf("example is invalid JSON: %v", err)
|
||||
}
|
||||
if example[0] != test.jsonKind {
|
||||
t.Fatalf("example = %s, want JSON kind %q", example, test.jsonKind)
|
||||
}
|
||||
switch value := decoded.(type) {
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
t.Fatal("example must not be an empty array")
|
||||
}
|
||||
case map[string]any:
|
||||
if len(value) == 0 {
|
||||
t.Fatal("example must not be an empty object")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wantUpdateFields := []string{"name", "unit", "paid", "per-hours", "when-can-leave", "visibility-rules"}
|
||||
assertSchemaContractConstraintGroup(t, payload.Tools["attendance.vacation_update_type"], "require_one_of", wantUpdateFields)
|
||||
assertSchemaContractConstraintGroup(t, payload.Tools["aitable.field_update"], "require_one_of", []string{"name", "config", "ai-config"})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type finalSchemaSafetyWant struct {
|
||||
canonical string
|
||||
effect string
|
||||
risk string
|
||||
confirmation string
|
||||
idempotency string
|
||||
}
|
||||
|
||||
func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent"},
|
||||
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.batch_update", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.range_batch_clear", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.group_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.sort_filter", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.ungroup_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
}
|
||||
assertFinalSchemaSafety(t, wants)
|
||||
}
|
||||
|
||||
func TestDevAppWriteGuardRequiresFinalSchemaConfirmation(t *testing.T) {
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "dev.add_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.apply_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.create_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.create_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.delete_dev_app", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.disable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.disable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.enable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.enable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.publish_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.remove_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.remove_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.set_extension_robot_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.set_extension_webapp_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.submit_robot_create_task", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.subscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.unsubscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.update_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.update_dev_app_security_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
}
|
||||
assertFinalSchemaSafety(t, wants)
|
||||
}
|
||||
|
||||
func assertFinalSchemaSafety(t *testing.T, wants []finalSchemaSafetyWant) {
|
||||
t.Helper()
|
||||
canonicals := make([]string, 0, len(wants))
|
||||
for _, want := range wants {
|
||||
canonicals = append(canonicals, want.canonical)
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
|
||||
for _, want := range wants {
|
||||
want := want
|
||||
t.Run(want.canonical, func(t *testing.T) {
|
||||
tool := payload.Tools[want.canonical]
|
||||
values := map[string]string{
|
||||
"effect": want.effect,
|
||||
"risk": want.risk,
|
||||
"confirmation": want.confirmation,
|
||||
"idempotency": want.idempotency,
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for field, expected := range values {
|
||||
if got := schemaContractString(tool[field]); got != expected {
|
||||
t.Errorf("%s = %q, want %q", field, got, expected)
|
||||
}
|
||||
if got := schemaContractString(provenance[field]["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestSheetFinalSchemaConfirmationMatchesRuntimeGuards closes the delivery
|
||||
// invariant from the final typed Schema to the executable Cobra leaf. The
|
||||
// runtime marker can only be installed by the command-local wrapper that also
|
||||
// executes the typed confirmation guard.
|
||||
func TestSheetFinalSchemaConfirmationMatchesRuntimeGuards(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
root := NewRootCommand()
|
||||
|
||||
schemaPaths := make(map[string]string)
|
||||
for canonical, tool := range snapshot.Tools {
|
||||
primaryPath := schemaContractString(tool["primary_cli_path"])
|
||||
if primaryPath == "" {
|
||||
primaryPath = schemaContractString(tool["cli_path"])
|
||||
}
|
||||
if !strings.HasPrefix(primaryPath, "sheet ") || schemaContractString(tool["confirmation"]) != "user_required" {
|
||||
continue
|
||||
}
|
||||
if previous := schemaPaths[primaryPath]; previous != "" {
|
||||
t.Fatalf("final Schema maps both %q and %q to Sheet path %q", previous, canonical, primaryPath)
|
||||
}
|
||||
schemaPaths[primaryPath] = canonical
|
||||
|
||||
command := exactCommandForTest(root, primaryPath)
|
||||
if command == nil {
|
||||
t.Errorf("%s final Schema path %q has no executable Cobra leaf", canonical, primaryPath)
|
||||
continue
|
||||
}
|
||||
if !helpers.HasSheetMutationConfirmationGuard(command) {
|
||||
t.Errorf("%s (%s) declares confirmation=user_required but has no command-local runtime guard", canonical, primaryPath)
|
||||
}
|
||||
}
|
||||
if len(schemaPaths) == 0 {
|
||||
t.Fatal("final Schema contains no Sheet confirmation=user_required leaves")
|
||||
}
|
||||
|
||||
guardedPaths := make(map[string]bool)
|
||||
rootPrefix := root.CommandPath() + " "
|
||||
var visit func(*cobra.Command)
|
||||
visit = func(command *cobra.Command) {
|
||||
if helpers.HasSheetMutationConfirmationGuard(command) {
|
||||
path := strings.TrimPrefix(command.CommandPath(), rootPrefix)
|
||||
guardedPaths[path] = true
|
||||
}
|
||||
for _, child := range command.Commands() {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
|
||||
var missingGuards, undeclaredGuards []string
|
||||
for path, canonical := range schemaPaths {
|
||||
if !guardedPaths[path] {
|
||||
missingGuards = append(missingGuards, canonical+" ("+path+")")
|
||||
}
|
||||
}
|
||||
for path := range guardedPaths {
|
||||
if schemaPaths[path] == "" {
|
||||
undeclaredGuards = append(undeclaredGuards, path)
|
||||
}
|
||||
}
|
||||
if len(missingGuards) != 0 || len(undeclaredGuards) != 0 {
|
||||
sort.Strings(missingGuards)
|
||||
sort.Strings(undeclaredGuards)
|
||||
t.Fatalf("final Sheet Schema confirmation set differs from command-local runtime guards: missing=%v undeclared=%v", missingGuards, undeclaredGuards)
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,9 @@ func newSkillSearchCommand() *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("query", "", "搜索关键词(必填)")
|
||||
_ = cmd.MarkFlagRequired("query")
|
||||
cmd.Flags().String("scopes", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能")
|
||||
cmd.Flags().String("source", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)")
|
||||
cmd.Flags().String("scopes", "", "查询范围(已废弃,请使用 --source)")
|
||||
_ = cmd.Flags().MarkDeprecated("scopes", "请使用 --source 替代")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -289,15 +291,18 @@ func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
|
||||
func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
keyword, _ := cmd.Flags().GetString("query")
|
||||
scopes, _ := cmd.Flags().GetString("scopes")
|
||||
source, _ := cmd.Flags().GetString("source")
|
||||
if source == "" {
|
||||
source, _ = 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)
|
||||
if source != "" {
|
||||
apiURL += "&source=" + url.QueryEscape(source)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user