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 | ||
|
|
4a717bd92f | ||
|
|
604ec5f50a | ||
|
|
27296ec426 | ||
|
|
6a38a168dd | ||
|
|
9771053d81 | ||
|
|
10c0c5083e | ||
|
|
a0187b5297 | ||
|
|
81991f1c07 | ||
|
|
836670ef50 | ||
|
|
53ce0a8303 | ||
|
|
78867f3601 | ||
|
|
37438659e6 | ||
|
|
3c12c835a3 | ||
|
|
389f83241f | ||
|
|
3714adc2db | ||
|
|
f37d0569a1 | ||
|
|
798b58bf3c | ||
|
|
d926bed3cc | ||
|
|
5ac180d3dd | ||
|
|
35e60407d3 | ||
|
|
ea46132cf6 | ||
|
|
478dc155e8 | ||
|
|
08ecb38a42 |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
+196
-21
@@ -5,17 +5,20 @@ 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
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -34,17 +37,50 @@ jobs:
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
- 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:
|
||||
@@ -57,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 开关消费。
|
||||
@@ -76,17 +188,6 @@ jobs:
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -94,7 +195,8 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
|
||||
# 只有官方仓库发 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
|
||||
@@ -108,3 +210,76 @@ jobs:
|
||||
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 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 默认关闭:国内 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 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 }}
|
||||
|
||||
@@ -42,5 +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.
|
||||
@@ -6,6 +6,86 @@ 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -258,6 +258,16 @@ dws --profile <name|corpId> contact user search --query "..." # run one comman
|
||||
|
||||
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>
|
||||
@@ -313,25 +323,35 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
|
||||
### Command Help and Schema
|
||||
|
||||
Product commands are compiled into the binary in static endpoint mode. Use `--help` and the bundled Agent Skills as the source of truth; `dws schema` is retained for helper-only schemas such as `dev.*`.
|
||||
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
|
||||
# Inspect the current compiled command surface
|
||||
# Confirm that the command exists and inspect accepted flags
|
||||
dws aitable record query --help
|
||||
|
||||
# Helper-only schema introspection
|
||||
dws schema "dev app create"
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
|
||||
# Construct the 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:
|
||||
|
||||
@@ -406,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>
|
||||
|
||||
@@ -496,12 +561,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema Introspection</strong> — helper-only schemas in static endpoint mode</summary>
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
|
||||
```bash
|
||||
dws schema # static endpoint mode note
|
||||
dws schema "dev app create" # view helper-only schema
|
||||
dws schema "dev app create" --jq '.tool.required' # view required fields
|
||||
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>
|
||||
|
||||
+79
-13
@@ -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 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -255,6 +255,16 @@ 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>
|
||||
@@ -310,25 +320,35 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
|
||||
### 命令帮助与 Schema
|
||||
|
||||
产品命令在静态端点模式下已经编译进二进制。Agent 以 `--help` 和内置 Skill 为事实源;`dws schema` 仅保留给 `dev.*` 等 helper-only schema 查询。
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
|
||||
```bash
|
||||
# 查看当前编译出的命令面
|
||||
# 确认命令存在并查看当前接受的 flags
|
||||
dws aitable record query --help
|
||||
|
||||
# helper-only schema 自省
|
||||
dws schema "dev app create"
|
||||
# 先在产品内发现命令,再查看选中 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 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
@@ -403,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>
|
||||
|
||||
@@ -493,12 +558,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema 自省</strong> — 静态端点模式下的 helper-only schema</summary>
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
|
||||
```bash
|
||||
dws schema # 静态端点模式提示
|
||||
dws schema "dev app create" # 查看 helper-only schema
|
||||
dws schema "dev app create" --jq '.tool.required' # 查看必填字段
|
||||
dws schema aitable # 发现产品命令
|
||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
# 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. `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 helper commands, and plugin commands.
|
||||
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, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: plugin-based dynamic command loader
|
||||
- `internal/cli`: catalog types and endpoint loader (static endpoint mode)
|
||||
- `internal/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
|
||||
|
||||
@@ -147,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. |
|
||||
|
||||
@@ -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.
|
||||
+38
-19
@@ -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 "dev app create" # Pretty helper-only schema view / helper-only schema 彩色分区展示
|
||||
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -51,40 +51,59 @@ dws contact user search --query "Alice" -o result.json
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
静态端点模式下,产品命令和 flag 以当前二进制的 `--help` 与内置 Skill 为准。`dws schema` 仅保留 helper-only 子树(如 `dev.*`)的 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 "dev app create" # CLI 空格路径
|
||||
dws schema --cli-path "dev app create" # 显式 flag(脚本友好,免转义)
|
||||
dws schema -f pretty "dev app create" # 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
|
||||
```
|
||||
|
||||
helper-only schema 以 CLI 路径为准;普通产品命令请使用 `dws <path> --help` 查看参数。
|
||||
兼容入口 `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 叶子名 / helper-only canonical path |
|
||||
| `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 "dev app create" --jq '.tool.parameters' # 只看参数 schema
|
||||
dws schema "dev app create" --jq '.tool.required' # 只看必填字段
|
||||
dws schema "calendar event create" --jq '.parameters' # 只看参数
|
||||
dws schema "calendar event create" --jq '[.parameters | to_entries[] | select(.value.required)]' # 只看 Agent required 参数
|
||||
```
|
||||
|
||||
## Shell Completion / 自动补全
|
||||
|
||||
@@ -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 上下文。
|
||||
@@ -3,15 +3,17 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
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/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
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
|
||||
@@ -35,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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
newAuthMigrateKeychainCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
@@ -283,6 +284,7 @@ var (
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
@@ -446,6 +448,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
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 {
|
||||
@@ -468,12 +471,15 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
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
|
||||
@@ -503,7 +509,10 @@ 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 进行登录")
|
||||
}
|
||||
}
|
||||
@@ -514,6 +523,65 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
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())
|
||||
@@ -589,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,
|
||||
))
|
||||
}
|
||||
@@ -1199,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"`
|
||||
@@ -1210,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.
|
||||
@@ -233,6 +352,91 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
@@ -824,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
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"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"
|
||||
@@ -29,6 +30,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var doctorKeychainDiagnose = keychain.Diagnose
|
||||
|
||||
// checkStatus represents the outcome of a single doctor check.
|
||||
type checkStatus string
|
||||
|
||||
@@ -76,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)
|
||||
|
||||
@@ -132,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 进行登录"
|
||||
@@ -182,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 {
|
||||
|
||||
@@ -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) {
|
||||
@@ -127,6 +134,108 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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=") {
|
||||
|
||||
@@ -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,10 +19,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
|
||||
@@ -73,25 +71,11 @@ func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes
|
||||
return overlay
|
||||
}
|
||||
|
||||
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
|
||||
// server using only its manifest + overlay.json.
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// Dynamic command building has been removed; this now simply registers the
|
||||
// server descriptor and returns nil commands.
|
||||
func registerStdioServerFromOverlay(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
runner executor.Runner,
|
||||
) ([]*cobra.Command, mcptypes.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, mcptypes.ServerDescriptor{}, false
|
||||
}
|
||||
|
||||
descriptor := mcptypes.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
@@ -105,11 +89,8 @@ func registerStdioServerFromOverlay(
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
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))
|
||||
|
||||
// Dynamic command tree building has been removed.
|
||||
_ = runner
|
||||
return nil, descriptor, true
|
||||
return descriptor
|
||||
}
|
||||
|
||||
+23
-285
@@ -24,7 +24,6 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -39,7 +38,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -66,6 +64,8 @@ func Execute() (exitCode int) {
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
CloseAuditSink() // Drain async audit forwards on all exit paths,
|
||||
// including command errors where Cobra skips PersistentPostRunE.
|
||||
timing.PrintIfEnabled()
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
}()
|
||||
@@ -335,6 +335,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
return closeOutputSink(cmd)
|
||||
},
|
||||
@@ -356,6 +357,8 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newCatalogCommand(loader),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newUpgradeCommand(),
|
||||
@@ -384,7 +387,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
fn(root, caller)
|
||||
deduplicateCommands(root)
|
||||
}
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
configureRootHelp(root)
|
||||
// Set custom flag error handler for better UX
|
||||
@@ -538,7 +540,7 @@ func newVersionCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
|
||||
return cli.NewSchemaCommand(loader)
|
||||
}
|
||||
|
||||
// buildMCPCommandFn is a test seam for newMCPCommand.
|
||||
@@ -559,10 +561,12 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"api": true,
|
||||
"audit": true,
|
||||
"cache": true,
|
||||
"config": true,
|
||||
"dev": true,
|
||||
"doctor": true,
|
||||
"event": true,
|
||||
"completion": true,
|
||||
"skill": true,
|
||||
"plugin": true,
|
||||
@@ -593,9 +597,9 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
// not override. This protects core CLI functionality from being hijacked
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "api": true, "login": true, "logout": true,
|
||||
"auth": true, "api": true, "audit": true, "login": true, "logout": true,
|
||||
"plugin": true, "profile": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "completion": true,
|
||||
"config": true, "doctor": true, "event": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
"schema": true, "mcp": true, "help": true,
|
||||
}
|
||||
@@ -667,43 +671,6 @@ func deduplicateCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// pluginColdTimeouts holds the cold-path discovery budget for plugin MCP
|
||||
// servers. Timeouts only apply to the *first* discovery for a given
|
||||
// plugin/server; subsequent startups take the warm cache path and bypass
|
||||
// the network entirely.
|
||||
type pluginColdTimeouts struct {
|
||||
httpNoAuth time.Duration
|
||||
httpAuth time.Duration
|
||||
stdio time.Duration
|
||||
}
|
||||
|
||||
// resolvePluginColdTimeouts returns the cold-discovery budget for plugin MCP
|
||||
// servers, applying the DWS_PLUGIN_COLD_TIMEOUT override when set. Defaults
|
||||
// are tuned so healthy cross-region HTTP endpoints succeed on a cold start
|
||||
// and Python/Node-based stdio plugins have headroom for interpreter load,
|
||||
// while an unreachable host still surrenders in bounded time.
|
||||
func resolvePluginColdTimeouts() pluginColdTimeouts {
|
||||
t := pluginColdTimeouts{
|
||||
httpNoAuth: 1 * time.Second,
|
||||
httpAuth: 1500 * time.Millisecond,
|
||||
stdio: 2 * time.Second,
|
||||
}
|
||||
raw := strings.TrimSpace(os.Getenv(cli.PluginColdTimeoutEnv))
|
||||
if raw == "" {
|
||||
return t
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
slog.Warn("plugin: ignoring invalid DWS_PLUGIN_COLD_TIMEOUT",
|
||||
"value", raw, "error", err)
|
||||
return t
|
||||
}
|
||||
t.httpNoAuth = d
|
||||
t.httpAuth = d
|
||||
t.stdio = d
|
||||
return t
|
||||
}
|
||||
|
||||
func configureOutputSink(cmd *cobra.Command) error {
|
||||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||||
return nil
|
||||
@@ -798,11 +765,10 @@ func CloseFileLogger() {
|
||||
}
|
||||
}
|
||||
|
||||
// loadPlugins scans plugin directories, injects their MCP servers into
|
||||
// the dynamic server registry, and registers their pipeline hooks.
|
||||
// This runs before legacy command construction so that plugin servers
|
||||
// are available for EnvironmentLoader.Load().
|
||||
func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
|
||||
// loadPlugins registers versioned plugin manifests, stdio clients, hooks, and
|
||||
// skills. It deliberately does not initialize MCP transports or call
|
||||
// tools/list while constructing the command tree.
|
||||
func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
pluginLoader := plugin.NewLoader(RawVersion())
|
||||
|
||||
// 0a. Inject plugin config values from settings.json as environment
|
||||
@@ -832,96 +798,21 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
|
||||
allPlugins := append(userPlugins, devPlugins...)
|
||||
|
||||
// 3. Discover tools from streamable-http servers and build CLI commands.
|
||||
// Third-party servers with auth headers are discovered in parallel
|
||||
// to avoid sequential 10s timeouts when multiple remote servers exist.
|
||||
var pluginCmds []*cobra.Command
|
||||
tc := transport.NewClient(nil)
|
||||
|
||||
// Collect all server descriptors and register auth first (fast, no I/O).
|
||||
type pluginServer struct {
|
||||
plugin *plugin.Plugin
|
||||
srv mcptypes.ServerDescriptor
|
||||
}
|
||||
var httpServers []pluginServer
|
||||
|
||||
// 3. Register HTTP descriptors and authentication from the manifest.
|
||||
for _, p := range allPlugins {
|
||||
for _, srv := range p.ToServerDescriptors() {
|
||||
AppendDynamicServer(srv)
|
||||
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
}
|
||||
|
||||
if srv.HasCLIMeta {
|
||||
httpServers = append(httpServers, pluginServer{plugin: p, srv: srv})
|
||||
}
|
||||
registerPluginHTTPServer(srv)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all stdio clients up front so HTTP + stdio discovery can run
|
||||
// concurrently — the slowest plugin (typically an unreachable HTTP
|
||||
// endpoint hitting its dial timeout) dominates the parallel wall-clock,
|
||||
// not the sum of every plugin's cold timeout.
|
||||
type stdioEntry struct {
|
||||
plugin *plugin.Plugin
|
||||
sc plugin.StdioServerClient
|
||||
}
|
||||
var stdioEntries []stdioEntry
|
||||
// 4. Register stdio descriptors and unstarted clients. The subprocess is
|
||||
// started and initialized only when a command is actually executed.
|
||||
for _, p := range allPlugins {
|
||||
for _, sc := range p.StdioClients(userCtx) {
|
||||
// Use background context so the subprocess lives for the CLI
|
||||
// process lifetime (not killed by a short timeout).
|
||||
if err := sc.Client.Start(context.Background()); err != nil {
|
||||
slog.Warn("plugin: failed to start stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
stdioEntries = append(stdioEntries, stdioEntry{plugin: p, sc: sc})
|
||||
registerStdioServerFromManifest(p, sc)
|
||||
}
|
||||
}
|
||||
|
||||
coldTimeouts := resolvePluginColdTimeouts()
|
||||
|
||||
// Phase A: stdio overlay-first registration (synchronous, no I/O).
|
||||
// Plugins whose overlay.json declares ToolOverrides register their
|
||||
// server descriptor up-front from manifest metadata alone.
|
||||
var legacyStdioEntries []stdioEntry
|
||||
for _, e := range stdioEntries {
|
||||
_, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner)
|
||||
if !ok {
|
||||
legacyStdioEntries = append(legacyStdioEntries, e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Phase B: fan out discovery in parallel.
|
||||
httpResults := make([][]*cobra.Command, len(httpServers))
|
||||
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
|
||||
var wg sync.WaitGroup
|
||||
for i, ps := range httpServers {
|
||||
wg.Add(1)
|
||||
go func(idx int, ps pluginServer) {
|
||||
defer wg.Done()
|
||||
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, coldTimeouts)
|
||||
}(i, ps)
|
||||
}
|
||||
// legacy stdio: discovery-first (commands depend on tool list).
|
||||
for i, e := range legacyStdioEntries {
|
||||
wg.Add(1)
|
||||
go func(idx int, e stdioEntry) {
|
||||
defer wg.Done()
|
||||
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, coldTimeouts)
|
||||
}(i, e)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, cmds := range httpResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
for _, cmds := range legacyStdioResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
|
||||
// 5. Register plugin hooks into pipeline engine
|
||||
if engine != nil {
|
||||
for _, p := range allPlugins {
|
||||
@@ -950,89 +841,14 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
)
|
||||
}
|
||||
|
||||
return pluginCmds
|
||||
}
|
||||
|
||||
// registerHTTPServer discovers tools from a streamable-http MCP server and
|
||||
// registers the server. Dynamic command building has been removed; this now
|
||||
// simply registers the server descriptor for direct runtime dispatch.
|
||||
func registerHTTPServer(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
|
||||
tools := discoverHTTPTools(p, srv, tc, timeouts)
|
||||
return buildHTTPCommandsFromTools(srv, tools, runner)
|
||||
}
|
||||
|
||||
// discoverHTTPTools performs the blocking Initialize + ListTools handshake
|
||||
// for an HTTP MCP server and returns the discovered tools. Returns nil on
|
||||
// any transport/protocol error; errors are logged at Debug level.
|
||||
func discoverHTTPTools(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||||
// Cold-path budget. An unreachable endpoint will burn the full window
|
||||
// via the TCP dial timeout; a healthy localhost/third-party endpoint
|
||||
// typically responds in <200 ms. Third-party servers with auth get a
|
||||
// slightly larger window to accommodate TLS + auth RTT. Operators with
|
||||
// cross-region endpoints can relax the window via DWS_PLUGIN_COLD_TIMEOUT.
|
||||
// TODO(remove-discovery): plugin discovery currently has no warm cache, so
|
||||
// unreachable endpoints still pay this timeout during command startup.
|
||||
timeout := timeouts.httpNoAuth
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
timeout = timeouts.httpAuth
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
discoveryClient := tc
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
discoveryClient = buildPluginAuthClient(tc, srv)
|
||||
}
|
||||
|
||||
if _, err := discoveryClient.Initialize(ctx, srv.Endpoint); err != nil {
|
||||
slog.Debug("plugin: http server offline, skipping tool discovery",
|
||||
"plugin", p.Manifest.Name, "server", srv.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
toolsResult, err := discoveryClient.ListTools(ctx, srv.Endpoint)
|
||||
if err != nil {
|
||||
slog.Debug("plugin: http ListTools failed",
|
||||
"plugin", p.Manifest.Name, "server", srv.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
return toolsResult.Tools
|
||||
}
|
||||
|
||||
// buildHTTPCommandsFromTools registers the server for direct runtime
|
||||
// dispatch. Dynamic command tree building has been removed.
|
||||
func buildHTTPCommandsFromTools(srv mcptypes.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
_ = srv
|
||||
_ = tools
|
||||
_ = runner
|
||||
// Dynamic command building from compat.BuildDynamicCommands has been removed.
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPluginAuthClient creates a transport.Client copy with the plugin's
|
||||
// Bearer token and trusted domains injected. This allows third-party MCP
|
||||
// servers that require independent authentication to be discovered at startup.
|
||||
func buildPluginAuthClient(base *transport.Client, srv mcptypes.ServerDescriptor) *transport.Client {
|
||||
authToken := ""
|
||||
extraHeaders := make(map[string]string)
|
||||
for key, value := range srv.AuthHeaders {
|
||||
if strings.EqualFold(key, "Authorization") {
|
||||
authToken = strings.TrimPrefix(value, "Bearer ")
|
||||
authToken = strings.TrimSpace(authToken)
|
||||
} else {
|
||||
extraHeaders[key] = value
|
||||
}
|
||||
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
|
||||
AppendDynamicServer(srv)
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
}
|
||||
if authToken == "" {
|
||||
return base
|
||||
}
|
||||
client := base.WithAuth(authToken, extraHeaders)
|
||||
// Trust the endpoint's hostname so the token is actually sent.
|
||||
if parsed, err := url.Parse(srv.Endpoint); err == nil {
|
||||
host := parsed.Hostname()
|
||||
client.TrustedDomains = []string{host, "*." + host}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// registerPluginAuthFromHeaders extracts authentication credentials from
|
||||
@@ -1069,84 +885,6 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
})
|
||||
}
|
||||
|
||||
// registerStdioServer initializes a stdio MCP server, discovers its tools,
|
||||
// and registers the StdioClient for runtime dispatch.
|
||||
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
|
||||
tools := discoverStdioTools(p, sc, timeouts)
|
||||
return buildStdioCommands(p, sc, tools, runner)
|
||||
}
|
||||
|
||||
// discoverStdioTools performs the blocking Initialize + ListTools handshake
|
||||
// on a stdio MCP subprocess. Returns nil on any error (logged at Debug level).
|
||||
// The default 2s budget comfortably accommodates Python/Node runtimes whose
|
||||
// interpreter + dependency load dominates the first response. Operators with
|
||||
// heavier startup chains can relax further via DWS_PLUGIN_COLD_TIMEOUT.
|
||||
//
|
||||
// A handshake failure here is an EXPECTED, benign outcome for an optional local
|
||||
// plugin: e.g. the conference plugin reports "本地服务未就绪" whenever the
|
||||
// DingTalk desktop client isn't running, which is the common case for anyone
|
||||
// not actively recording a meeting. Discovery simply yields no tools and the
|
||||
// run proceeds — commands that ship toolOverrides still register up-front via
|
||||
// registerStdioServerFromOverlay (Phase A), so availability is unaffected.
|
||||
//
|
||||
// These run during command-tree construction (NewRootCommandWithEngine), which
|
||||
// happens BEFORE PersistentPreRunE applies --debug/--verbose via
|
||||
// configureLogLevel. So a Warn here printed to stderr on EVERY invocation
|
||||
// regardless of flags, polluting output and misleading callers into treating it
|
||||
// as the cause of an unrelated command error (e.g. an auth or PARAM_ERROR from a
|
||||
// completely different server). Logging at Debug keeps the discovery miss out of
|
||||
// normal output; surfacing it would require configuring the log level before the
|
||||
// tree is built, which we deliberately avoid this close to release.
|
||||
func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeouts.stdio)
|
||||
defer cancel()
|
||||
|
||||
if _, err := sc.Client.Initialize(ctx); err != nil {
|
||||
slog.Debug("plugin: stdio initialize failed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
toolsResult, err := sc.Client.ListTools(ctx)
|
||||
if err != nil {
|
||||
slog.Debug("plugin: stdio ListTools failed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
return toolsResult.Tools
|
||||
}
|
||||
|
||||
// buildStdioCommands registers the stdio client and server descriptor
|
||||
// for direct runtime dispatch. Dynamic command tree building has been removed.
|
||||
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio server has no tools",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
|
||||
descriptor := mcptypes.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
slog.Debug("plugin: stdio server registered",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"tools", len(tools))
|
||||
|
||||
_ = runner
|
||||
return nil
|
||||
}
|
||||
|
||||
// newPipelineEngine creates and configures the pipeline engine with
|
||||
// handlers for all five pipeline phases. The phases execute in order:
|
||||
// Register → PreParse → PostParse → PreRequest → PostResponse.
|
||||
|
||||
@@ -27,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"
|
||||
@@ -142,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +155,22 @@ 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
|
||||
@@ -468,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.
|
||||
@@ -704,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill)
|
||||
dws skill setup --mode mono --yes # 非交互装 mono
|
||||
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
|
||||
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
|
||||
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
|
||||
dws skill setup --mode multi -x live -x devdoc # 安装除 live、devdoc 外的其余 skill
|
||||
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillSetup,
|
||||
@@ -128,6 +128,19 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
|
||||
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
|
||||
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
|
||||
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc)
|
||||
fmt.Fprintln(out, "将安装到:")
|
||||
for _, d := range dests {
|
||||
fmt.Fprintf(out, " - %s\n", d)
|
||||
}
|
||||
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
|
||||
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
|
||||
if err != nil {
|
||||
@@ -203,8 +216,7 @@ func normalizeMultiSkillName(name string) string {
|
||||
return multiSkillPrefix + n
|
||||
}
|
||||
|
||||
// filterMultiSkillNames narrows `all` by include / exclude lists.
|
||||
// Semantics mirror lark-cli's `npx skills add -s lark-calendar`:
|
||||
// filterMultiSkillNames narrows `all` by include / exclude lists:
|
||||
//
|
||||
// - include + exclude are mutually exclusive (both → error)
|
||||
// - names accept short or full form; normalized before matching
|
||||
@@ -493,7 +505,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
|
||||
@@ -44,6 +44,11 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
t.Errorf("expected embedded skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
|
||||
t.Fatal("embedded mono skill must not contain build-only schema-hints")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat embedded mono schema-hints: %v", err)
|
||||
}
|
||||
|
||||
// cleanup must actually remove the temp dir.
|
||||
cleanup()
|
||||
@@ -52,6 +57,35 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and the PAT product skill. Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeEmbeddedSkillSource: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if !isSkillSourceRoot(dir, skillSetupModeMulti) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dws-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "references", "pat.md"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
|
||||
t.Fatal("embedded multi skill must not contain build-only schema-hints")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat embedded multi schema-hints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
|
||||
// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
|
||||
// rather than probing the current working directory (the stale-skill footgun).
|
||||
|
||||
@@ -445,8 +445,7 @@ func TestFilterMultiSkillNames(t *testing.T) {
|
||||
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
|
||||
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
|
||||
// touch already-installed dingtalk-* siblings (additive semantics, matches
|
||||
// lark-cli `npx skills add -s lark-calendar`).
|
||||
// touch already-installed dingtalk-* siblings (additive semantics).
|
||||
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
|
||||
|
||||
@@ -35,6 +35,11 @@ import (
|
||||
// Setting keychain.StorageDirEnv here forces every keychain read/write in
|
||||
// this binary into a per-process tempdir, eliminating that contamination
|
||||
// without touching production code.
|
||||
//
|
||||
// PAT authorization tests also exercise code paths that normally open the
|
||||
// system browser. Keep the package-wide default opener inert so running the
|
||||
// test binary never launches a page on the developer's machine; tests that
|
||||
// need to assert the URL can still replace openBrowserFunc locally.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
|
||||
if err != nil {
|
||||
@@ -44,6 +49,7 @@ func TestMain(m *testing.M) {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
openBrowserFunc = func(string) error { return nil }
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
|
||||
@@ -15,6 +15,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
@@ -36,6 +37,21 @@ func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.To
|
||||
|
||||
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
|
||||
// Defense in depth for direct helper callers: global dry-run must never
|
||||
// reach an injected/real Runner, even if a command bypasses the normal
|
||||
// Schema leaf wrapper. EchoRunner produces the same stable dry_run envelope
|
||||
// without catalog, auth, Keychain, endpoint or transport access.
|
||||
if a != nil && a.DryRun() {
|
||||
inv.DryRun = true
|
||||
result, err := (executor.EchoRunner{}).Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertResult(result), nil
|
||||
}
|
||||
if a == nil || a.runner == nil {
|
||||
return nil, fmt.Errorf("ToolCaller runner is not configured")
|
||||
}
|
||||
result, err := a.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -44,14 +60,28 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Format() string {
|
||||
if a.flags != nil {
|
||||
if a != nil && a.flags != nil {
|
||||
return a.flags.Format
|
||||
}
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) DryRun() bool {
|
||||
return a.flags != nil && a.flags.DryRun
|
||||
return a != nil && a.flags != nil && a.flags.DryRun
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Fields() string {
|
||||
if a != nil && a.flags != nil {
|
||||
return a.flags.Fields
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) JQ() string {
|
||||
if a != nil && a.flags != nil {
|
||||
return a.flags.JQ
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func convertResult(r executor.Result) *edition.ToolResult {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFileSinkEmit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
defer sink.Close()
|
||||
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "abc123",
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "calendar",
|
||||
Command: "list_events",
|
||||
Endpoint: "https://api.example.com/mcp",
|
||||
Result: "success",
|
||||
DurationMs: 150,
|
||||
CLIVersion: "1.0.47",
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
}
|
||||
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if evt.Hash == "" {
|
||||
t.Error("expected hash to be set")
|
||||
}
|
||||
if evt.PrevHash != "" {
|
||||
t.Error("first event should have empty prev_hash")
|
||||
}
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded Event
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.ExecutionID != "abc123" {
|
||||
t.Errorf("got execution_id=%s, want abc123", decoded.ExecutionID)
|
||||
}
|
||||
if decoded.Hash == "" {
|
||||
t.Error("decoded hash should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainIntegrity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "exec-" + string(rune('a'+i)),
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "test",
|
||||
Command: "cmd",
|
||||
Result: "success",
|
||||
DurationMs: int64(i * 10),
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sink.Close()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid, brokenAt, err := VerifyFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Errorf("expected valid chain, broken at line %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainDetectsTampering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "exec-" + string(rune('0'+i)),
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "test",
|
||||
Command: "cmd",
|
||||
Result: "success",
|
||||
DurationMs: 100,
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sink.Close()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Tamper with the file: modify a character in the second line
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Find second newline and change a char after it
|
||||
lines := splitLines(data)
|
||||
if len(lines) < 2 {
|
||||
t.Fatal("expected at least 2 lines")
|
||||
}
|
||||
// Corrupt the second line by changing first char of product
|
||||
var evt2 map[string]any
|
||||
json.Unmarshal([]byte(lines[1]), &evt2)
|
||||
evt2["product"] = "tampered"
|
||||
tampered, _ := json.Marshal(evt2)
|
||||
lines[1] = string(tampered)
|
||||
|
||||
corrupted := []byte(lines[0] + "\n" + lines[1] + "\n" + lines[2] + "\n")
|
||||
os.WriteFile(file, corrupted, 0o600)
|
||||
|
||||
valid, brokenAt, _ := VerifyFile(file)
|
||||
if valid {
|
||||
t.Error("expected invalid chain after tampering")
|
||||
}
|
||||
if brokenAt != 2 {
|
||||
t.Errorf("expected break at line 2, got %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetention(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create old files
|
||||
oldDate := time.Now().AddDate(0, 0, -100).Format("20060102")
|
||||
recentDate := time.Now().AddDate(0, 0, -10).Format("20060102")
|
||||
os.WriteFile(filepath.Join(dir, "audit-"+oldDate+".jsonl"), []byte("old"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "audit-"+recentDate+".jsonl"), []byte("recent"), 0o600)
|
||||
|
||||
_, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Give async pruning a moment
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "audit-"+oldDate+".jsonl")); !os.IsNotExist(err) {
|
||||
t.Error("expected old file to be pruned")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "audit-"+recentDate+".jsonl")); err != nil {
|
||||
t.Error("recent file should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactEvent(t *testing.T) {
|
||||
evt := Event{
|
||||
Actor: Actor{UserID: "uid123", Name: "张三", CorpID: "corp1", CorpName: "公司A"},
|
||||
Product: "calendar",
|
||||
Command: "list",
|
||||
ParamsSummary: `{"date":"2026-01-01"}`,
|
||||
Result: "success",
|
||||
}
|
||||
|
||||
hashed := RedactEvent(evt, RedactHashed)
|
||||
if hashed.Actor.Name == "张三" {
|
||||
t.Error("name should be hashed")
|
||||
}
|
||||
if hashed.ParamsSummary != "" {
|
||||
t.Error("params should be cleared in hashed mode")
|
||||
}
|
||||
if hashed.Actor.UserID != "uid123" {
|
||||
t.Error("user_id should remain in hashed mode")
|
||||
}
|
||||
|
||||
minimal := RedactEvent(evt, RedactMinimal)
|
||||
if minimal.Actor.UserID == "uid123" {
|
||||
t.Error("user_id should be hashed in minimal mode")
|
||||
}
|
||||
if minimal.Endpoint != "" {
|
||||
t.Error("endpoint should be cleared in minimal mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNopSink(t *testing.T) {
|
||||
var s NopSink
|
||||
if err := s.Emit(&Event{}); err != nil {
|
||||
t.Error("NopSink.Emit should not error")
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Error("NopSink.Close should not error")
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(data []byte) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == '\n' {
|
||||
if i > start {
|
||||
lines = append(lines, string(data[start:i]))
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
lines = append(lines, string(data[start:]))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Chain computes the L1 sha256 tamper-evidence chain. It is deliberately
|
||||
// stateless: every seal derives prev_hash from the last record already present
|
||||
// in the current day's file rather than from an in-memory or sidecar cursor.
|
||||
//
|
||||
// This makes the chain correct in two cases the previous global-sidecar design
|
||||
// broke:
|
||||
// - cross-date: each day rotates to a fresh file whose first record chains
|
||||
// from "", so every file is independently verifiable by VerifyFile.
|
||||
// - cross-process: because the caller holds an exclusive inter-process lock on
|
||||
// the file while sealing, the tail read reflects records written by any
|
||||
// other dws process, so concurrent writers cannot fork the chain.
|
||||
type Chain struct{}
|
||||
|
||||
// NewChain keeps the historical constructor signature. The directory argument
|
||||
// is no longer needed because prev_hash is derived from the target file.
|
||||
func NewChain(string) *Chain { return &Chain{} }
|
||||
|
||||
// SealFromFile reads the hash of the last record in f (the current day's audit
|
||||
// file) and returns the prev_hash / hash pair for the event whose hash-free
|
||||
// body is provided. The caller must hold the file lock.
|
||||
func (c *Chain) SealFromFile(f *os.File, body []byte) (prevHash, hash string, err error) {
|
||||
prevHash, err = lastRecordHash(f)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return prevHash, ComputeHash(prevHash, body), nil
|
||||
}
|
||||
|
||||
// lastRecordHash returns the "hash" field of the final non-empty JSONL record in
|
||||
// f, or "" when the file is empty. It reads only the tail of the file so cost
|
||||
// does not grow with file size.
|
||||
func lastRecordHash(f *os.File) (string, error) {
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
size := fi.Size()
|
||||
if size == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
const tailWindow = 64 * 1024
|
||||
start := size - tailWindow
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
buf := make([]byte, size-start)
|
||||
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Trim trailing newlines, then isolate the last line within the window.
|
||||
end := len(buf)
|
||||
for end > 0 && (buf[end-1] == '\n' || buf[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
if end == 0 {
|
||||
return "", nil
|
||||
}
|
||||
lineStart := end
|
||||
for lineStart > 0 && buf[lineStart-1] != '\n' {
|
||||
lineStart--
|
||||
}
|
||||
last := buf[lineStart:end]
|
||||
|
||||
var rec struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(last, &rec); err != nil {
|
||||
// The last record spilled past our tail window (pathologically large
|
||||
// line). Fall back to a full scan for correctness.
|
||||
if lineStart == 0 && start > 0 {
|
||||
return lastRecordHashFullScan(f)
|
||||
}
|
||||
return "", fmt.Errorf("audit: parse last record: %w", err)
|
||||
}
|
||||
return rec.Hash, nil
|
||||
}
|
||||
|
||||
func lastRecordHashFullScan(f *os.File) (string, error) {
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
|
||||
var last []byte
|
||||
for scanner.Scan() {
|
||||
if b := scanner.Bytes(); len(b) > 0 {
|
||||
last = append(last[:0], b...)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(last) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var rec struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(last, &rec); err != nil {
|
||||
return "", fmt.Errorf("audit: parse last record: %w", err)
|
||||
}
|
||||
return rec.Hash, nil
|
||||
}
|
||||
|
||||
func VerifyFile(path string) (valid bool, brokenAt int, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
|
||||
|
||||
prevHash := ""
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
|
||||
var evt struct {
|
||||
PrevHash string `json:"prev_hash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
return false, lineNum, fmt.Errorf("line %d: invalid JSON: %w", lineNum, err)
|
||||
}
|
||||
|
||||
if evt.PrevHash != prevHash {
|
||||
return false, lineNum, fmt.Errorf("line %d: prev_hash mismatch", lineNum)
|
||||
}
|
||||
|
||||
body := stripHashFields(line)
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prevHash))
|
||||
h.Write(body)
|
||||
expected := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
if evt.Hash != expected {
|
||||
return false, lineNum, fmt.Errorf("line %d: hash mismatch", lineNum)
|
||||
}
|
||||
|
||||
prevHash = evt.Hash
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false, lineNum, err
|
||||
}
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func stripHashFields(line []byte) []byte {
|
||||
var evt Event
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
return line
|
||||
}
|
||||
evt.PrevHash = ""
|
||||
evt.Hash = ""
|
||||
out, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ComputeHash(prevHash string, eventJSON []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prevHash))
|
||||
h.Write(eventJSON)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvAudit = "DWS_AUDIT"
|
||||
EnvAuditDir = "DWS_AUDIT_DIR"
|
||||
EnvRetentionDays = "DWS_AUDIT_RETENTION_DAYS"
|
||||
EnvForwardURL = "DWS_AUDIT_FORWARD_URL"
|
||||
EnvForwardToken = "DWS_AUDIT_FORWARD_TOKEN"
|
||||
EnvForwardRedact = "DWS_AUDIT_FORWARD_REDACT"
|
||||
EnvAuditDebug = "DWS_AUDIT_DEBUG"
|
||||
|
||||
defaultRetentionDays = 90
|
||||
auditSubdir = "audit"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAudit,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "操作审计日志开关(默认启用,设 0/false/off 关闭)",
|
||||
DefaultValue: "启用",
|
||||
Example: "0",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAuditDir,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计日志目录(默认 <configDir>/audit)",
|
||||
Example: "/var/log/dws-audit",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvRetentionDays,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计日志留存天数",
|
||||
DefaultValue: "90",
|
||||
Example: "180",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardURL,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计事件远端转发 URL(POST JSON)",
|
||||
Example: "https://siem.example.com/audit",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardToken,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "远端转发 Bearer Token",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardRedact,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "远端转发脱敏级别:none / hashed / minimal",
|
||||
DefaultValue: "none",
|
||||
Example: "hashed",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAuditDebug,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "打印审计子系统初始化/写入/转发失败诊断到 stderr(设 1/true/on 开启)",
|
||||
Example: "1",
|
||||
})
|
||||
}
|
||||
|
||||
// DebugEnabled reports whether audit-subsystem diagnostics should be surfaced to
|
||||
// stderr. Failures are always eligible for the structured log; this gates the
|
||||
// noisier stderr channel.
|
||||
func DebugEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvAuditDebug))) {
|
||||
case "1", "true", "on", "yes", "y":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsEnabled() bool {
|
||||
v := os.Getenv(EnvAudit)
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "0", "false", "off", "no", "n":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BuildSink constructs the audit sink for configDir. It returns an error when
|
||||
// the audit subsystem is enabled but cannot initialize (e.g. the log directory
|
||||
// is not writable) so the caller can surface the failure instead of silently
|
||||
// degrading. report receives non-fatal forwarder diagnostics; it may be nil.
|
||||
func BuildSink(configDir string, report func(format string, args ...any)) (Sink, error) {
|
||||
if !IsEnabled() {
|
||||
return NopSink{}, nil
|
||||
}
|
||||
|
||||
dir := os.Getenv(EnvAuditDir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join(configDir, auditSubdir)
|
||||
}
|
||||
|
||||
retention := defaultRetentionDays
|
||||
if v := os.Getenv(EnvRetentionDays); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
retention = n
|
||||
}
|
||||
}
|
||||
|
||||
writer, err := NewDateRotatingWriter(dir, retention)
|
||||
if err != nil {
|
||||
return NopSink{}, err
|
||||
}
|
||||
|
||||
chain := NewChain(dir)
|
||||
|
||||
var forwarder *HTTPForwarder
|
||||
if fwdURL := os.Getenv(EnvForwardURL); fwdURL != "" {
|
||||
token := os.Getenv(EnvForwardToken)
|
||||
redact := RedactLevel(strings.ToLower(os.Getenv(EnvForwardRedact)))
|
||||
if redact != RedactHashed && redact != RedactMinimal {
|
||||
redact = RedactNone
|
||||
}
|
||||
forwarder = NewHTTPForwarder(fwdURL, token, redact, report)
|
||||
}
|
||||
|
||||
return NewFileSink(writer, chain, forwarder), nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package audit
|
||||
|
||||
import "time"
|
||||
|
||||
type Event struct {
|
||||
Timestamp time.Time `json:"ts"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
Actor Actor `json:"actor"`
|
||||
Product string `json:"product"`
|
||||
Command string `json:"command"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ParamsSummary string `json:"params_summary,omitempty"`
|
||||
Result string `json:"result"`
|
||||
ErrCategory string `json:"error_category,omitempty"`
|
||||
ErrReason string `json:"error_reason,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CLIVersion string `json:"cli_version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Actor struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CorpID string `json:"corp_id"`
|
||||
CorpName string `json:"corp_name,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// lockFile takes a non-blocking exclusive advisory lock; returns an error when
|
||||
// another process holds it so the caller can retry with a timeout.
|
||||
func lockFile(f *os.File) error {
|
||||
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
}
|
||||
|
||||
func unlockFile(f *os.File) {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const lockfileExclusiveLock = 0x00000002
|
||||
|
||||
// lockFile takes a non-blocking exclusive lock on the first byte range; returns
|
||||
// an error when another process holds it so the caller can retry with a timeout.
|
||||
func lockFile(f *os.File) error {
|
||||
ol := new(windows.Overlapped)
|
||||
return windows.LockFileEx(
|
||||
windows.Handle(f.Fd()),
|
||||
lockfileExclusiveLock|windows.LOCKFILE_FAIL_IMMEDIATELY,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
(*windows.Overlapped)(unsafe.Pointer(ol)),
|
||||
)
|
||||
}
|
||||
|
||||
func unlockFile(f *os.File) {
|
||||
ol := new(windows.Overlapped)
|
||||
_ = windows.UnlockFileEx(
|
||||
windows.Handle(f.Fd()),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
(*windows.Overlapped)(unsafe.Pointer(ol)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPForwarder struct {
|
||||
url string
|
||||
token string
|
||||
redact RedactLevel
|
||||
client *http.Client
|
||||
wg sync.WaitGroup
|
||||
report func(format string, args ...any)
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewHTTPForwarder(url, token string, redact RedactLevel, report func(string, ...any)) *HTTPForwarder {
|
||||
if report == nil {
|
||||
report = func(string, ...any) {}
|
||||
}
|
||||
return &HTTPForwarder{
|
||||
url: url,
|
||||
token: token,
|
||||
redact: redact,
|
||||
client: &http.Client{Timeout: 3 * time.Second},
|
||||
report: report,
|
||||
timeout: 3 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Forward dispatches the event asynchronously while tracking the goroutine so
|
||||
// Close can wait for delivery instead of the CLI dropping it on exit.
|
||||
func (f *HTTPForwarder) Forward(evt Event) {
|
||||
f.wg.Add(1)
|
||||
go func() {
|
||||
defer f.wg.Done()
|
||||
f.send(evt)
|
||||
}()
|
||||
}
|
||||
|
||||
// Close waits for in-flight forwards to finish, bounded by ctx (and, if ctx has
|
||||
// no deadline, by a small internal timeout) so shutdown never blocks forever.
|
||||
func (f *HTTPForwarder) Close(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, f.timeout+2*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
f.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
f.report("forward flush timed out: %v", ctx.Err())
|
||||
return fmt.Errorf("audit: forward flush timed out: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (f *HTTPForwarder) send(evt Event) {
|
||||
var body []byte
|
||||
var err error
|
||||
if f.redact != RedactNone {
|
||||
body, err = RedactEventJSON(evt, f.redact)
|
||||
} else {
|
||||
body, err = json.Marshal(evt)
|
||||
}
|
||||
if err != nil {
|
||||
f.report("forward marshal failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
f.report("forward build request failed: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if f.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+f.token)
|
||||
}
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
f.report("forward request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
f.report("forward rejected: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type RedactLevel string
|
||||
|
||||
const (
|
||||
RedactNone RedactLevel = "none"
|
||||
RedactHashed RedactLevel = "hashed"
|
||||
RedactMinimal RedactLevel = "minimal"
|
||||
)
|
||||
|
||||
func RedactEvent(evt Event, level RedactLevel) Event {
|
||||
switch level {
|
||||
case RedactHashed:
|
||||
if evt.Actor.Name != "" {
|
||||
evt.Actor.Name = hashString(evt.Actor.Name)
|
||||
}
|
||||
if evt.Actor.CorpName != "" {
|
||||
evt.Actor.CorpName = hashString(evt.Actor.CorpName)
|
||||
}
|
||||
evt.ParamsSummary = ""
|
||||
case RedactMinimal:
|
||||
evt.Actor = Actor{UserID: hashString(evt.Actor.UserID), CorpID: hashString(evt.Actor.CorpID)}
|
||||
evt.ParamsSummary = ""
|
||||
evt.Endpoint = ""
|
||||
evt.ErrReason = ""
|
||||
evt.AgentID = ""
|
||||
evt.PrevHash = ""
|
||||
evt.Hash = ""
|
||||
}
|
||||
return evt
|
||||
}
|
||||
|
||||
func RedactEventJSON(evt Event, level RedactLevel) ([]byte, error) {
|
||||
redacted := RedactEvent(evt, level)
|
||||
return json.Marshal(redacted)
|
||||
}
|
||||
|
||||
func hashString(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeEvent seals and appends one event through the writer+chain, mirroring
|
||||
// FileSink.Emit's locking discipline, so tests exercise the real tail-derived
|
||||
// hash chain path.
|
||||
func writeEvent(t *testing.T, w *DateRotatingWriter, chain *Chain, evt *Event) {
|
||||
t.Helper()
|
||||
body, err := marshalWithoutHash(evt)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
f, release, err := w.beginAppend()
|
||||
if err != nil {
|
||||
t.Fatalf("beginAppend: %v", err)
|
||||
}
|
||||
prev, hash, err := chain.SealFromFile(f, body)
|
||||
if err != nil {
|
||||
release()
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
evt.PrevHash, evt.Hash = prev, hash
|
||||
line, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
release()
|
||||
t.Fatalf("marshal final: %v", err)
|
||||
}
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
release()
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func sampleEvent(id string) *Event {
|
||||
return &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: id,
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "calendar",
|
||||
Command: "event_list",
|
||||
Result: "success",
|
||||
DurationMs: 10,
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossDateChainIndependentPerFile verifies each day's file starts a fresh
|
||||
// chain (prev_hash="") and verifies independently — the bug the removed global
|
||||
// .chain sidecar introduced.
|
||||
func TestCrossDateChainIndependentPerFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chain := NewChain(dir)
|
||||
|
||||
// Simulate two calendar days by writing files directly with the same chain
|
||||
// semantics: each file's first record must chain from "".
|
||||
for _, day := range []string{"20260101", "20260102"} {
|
||||
path := filepath.Join(dir, "audit-"+day+".jsonl")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
evt := sampleEvent(fmt.Sprintf("%s-%d", day, i))
|
||||
body, _ := marshalWithoutHash(evt)
|
||||
prev, hash, err := chain.SealFromFile(f, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evt.PrevHash, evt.Hash = prev, hash
|
||||
line, _ := json.Marshal(evt)
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
valid, brokenAt, err := VerifyFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("verify %s: %v", day, err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatalf("file %s chain broken at line %d", day, brokenAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossProcessChainSharedFile simulates two independent writers (as two
|
||||
// processes would) appending to the same day's file. Because each seal derives
|
||||
// prev_hash from the file tail under the inter-process lock, the resulting chain
|
||||
// must remain valid with no fork.
|
||||
func TestCrossProcessChainSharedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
w1, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w1.Close()
|
||||
w2, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w2.Close()
|
||||
chain := NewChain(dir)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
w := w1
|
||||
if i%2 == 1 {
|
||||
w = w2
|
||||
}
|
||||
go func(w *DateRotatingWriter, i int) {
|
||||
defer wg.Done()
|
||||
writeEvent(t, w, chain, sampleEvent(fmt.Sprintf("exec-%d", i)))
|
||||
}(w, i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid, brokenAt, err := VerifyFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Fatalf("cross-process chain broken at line %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderCloseWaitsForDelivery ensures Close blocks until every async
|
||||
// forward has been delivered to the remote endpoint.
|
||||
func TestForwarderCloseWaitsForDelivery(t *testing.T) {
|
||||
var received int64
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release // hold the handler so delivery is still in flight at Close time
|
||||
atomic.AddInt64(&received, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
fwd := NewHTTPForwarder(srv.URL, "", RedactNone, nil)
|
||||
const n = 5
|
||||
for i := 0; i < n; i++ {
|
||||
fwd.Forward(*sampleEvent(fmt.Sprintf("e-%d", i)))
|
||||
}
|
||||
|
||||
// Nothing delivered yet because handlers are blocked.
|
||||
if got := atomic.LoadInt64(&received); got != 0 {
|
||||
t.Fatalf("expected 0 delivered before release, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
if err := fwd.Close(context.Background()); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&received); got != n {
|
||||
t.Fatalf("expected %d delivered after Close, got %d", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderCloseTimeoutReports verifies Close honors the ctx deadline and
|
||||
// reports instead of blocking forever when the endpoint never responds.
|
||||
func TestForwarderCloseTimeoutReports(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-block
|
||||
}))
|
||||
// Defers run LIFO: close(block) first unblocks the handler so srv.Close can
|
||||
// join its connection goroutine without deadlocking.
|
||||
defer srv.Close()
|
||||
defer close(block)
|
||||
|
||||
var reported int64
|
||||
report := func(string, ...any) { atomic.AddInt64(&reported, 1) }
|
||||
fwd := NewHTTPForwarder(srv.URL, "", RedactNone, report)
|
||||
fwd.timeout = 10 * time.Second // keep the in-flight send alive past ctx
|
||||
fwd.Forward(*sampleEvent("stuck"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := fwd.Close(ctx); err == nil {
|
||||
t.Fatal("expected timeout error from Close")
|
||||
}
|
||||
if atomic.LoadInt64(&reported) == 0 {
|
||||
t.Fatal("expected Close timeout to be reported")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSinkInitFailureObservable verifies BuildSink surfaces an error (and
|
||||
// the caller can fall back) when the audit directory cannot be created.
|
||||
func TestBuildSinkInitFailureObservable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Make a file where the audit subdir is expected so MkdirAll fails.
|
||||
clash := filepath.Join(dir, "audit")
|
||||
if err := os.WriteFile(clash, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(EnvAuditDir, filepath.Join(clash, "sub"))
|
||||
|
||||
var reported int64
|
||||
_, err := BuildSink(dir, func(string, ...any) { atomic.AddInt64(&reported, 1) })
|
||||
if err == nil {
|
||||
t.Fatal("expected BuildSink to fail when audit dir is unusable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
auditLockFile = ".audit.lock"
|
||||
auditLockTimeout = 3 * time.Second
|
||||
auditLockRetry = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
type DateRotatingWriter struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
curDate string
|
||||
file *os.File
|
||||
lock *os.File
|
||||
retention int
|
||||
}
|
||||
|
||||
func NewDateRotatingWriter(dir string, retentionDays int) (*DateRotatingWriter, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("audit: create dir: %w", err)
|
||||
}
|
||||
lock, err := os.OpenFile(filepath.Join(dir, auditLockFile), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit: open lock file: %w", err)
|
||||
}
|
||||
w := &DateRotatingWriter{
|
||||
dir: dir,
|
||||
retention: retentionDays,
|
||||
lock: lock,
|
||||
}
|
||||
go w.pruneOldFiles()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// beginAppend serializes writers within this process (mu) and across processes
|
||||
// (flock), rotates to today's file, and returns the open handle plus a release
|
||||
// func that unlocks in reverse order. The file is opened O_RDWR|O_APPEND so the
|
||||
// chain can read the tail while every write still lands atomically at EOF even
|
||||
// when another dws process appends concurrently.
|
||||
func (w *DateRotatingWriter) beginAppend() (*os.File, func(), error) {
|
||||
w.mu.Lock()
|
||||
if err := w.acquireLock(); err != nil {
|
||||
w.mu.Unlock()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
today := time.Now().Format("20060102")
|
||||
if today != w.curDate || w.file == nil {
|
||||
if w.file != nil {
|
||||
_ = w.file.Close()
|
||||
w.file = nil
|
||||
}
|
||||
path := filepath.Join(w.dir, fmt.Sprintf("audit-%s.jsonl", today))
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
unlockFile(w.lock)
|
||||
w.mu.Unlock()
|
||||
return nil, nil, fmt.Errorf("audit: open file: %w", err)
|
||||
}
|
||||
w.file = f
|
||||
w.curDate = today
|
||||
}
|
||||
|
||||
release := func() {
|
||||
unlockFile(w.lock)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
return w.file, release, nil
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) acquireLock() error {
|
||||
deadline := time.Now().Add(auditLockTimeout)
|
||||
for {
|
||||
if err := lockFile(w.lock); err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("audit: timeout acquiring file lock after %v (another dws process may be writing)", auditLockTimeout)
|
||||
}
|
||||
time.Sleep(auditLockRetry)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
var firstErr error
|
||||
if w.file != nil {
|
||||
if err := w.file.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
w.file = nil
|
||||
}
|
||||
if w.lock != nil {
|
||||
if err := w.lock.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
w.lock = nil
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) pruneOldFiles() {
|
||||
if w.retention <= 0 {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(w.dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -w.retention).Format("20060102")
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "audit-") || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
dateStr := strings.TrimPrefix(name, "audit-")
|
||||
dateStr = strings.TrimSuffix(dateStr, ".jsonl")
|
||||
if len(dateStr) != 8 {
|
||||
continue
|
||||
}
|
||||
if dateStr < cutoff {
|
||||
_ = os.Remove(filepath.Join(w.dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DateRotatingWriter) Dir() string {
|
||||
return w.dir
|
||||
}
|
||||
|
||||
func LatestAuditFile(dir string) (string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "audit-") && strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no audit files found in %s", dir)
|
||||
}
|
||||
sort.Strings(files)
|
||||
return filepath.Join(dir, files[len(files)-1]), nil
|
||||
}
|
||||
|
||||
func AuditFilesInRange(dir, since, until string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "audit-") || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
dateStr := strings.TrimPrefix(name, "audit-")
|
||||
dateStr = strings.TrimSuffix(dateStr, ".jsonl")
|
||||
if len(dateStr) != 8 {
|
||||
continue
|
||||
}
|
||||
if (since == "" || dateStr >= since) && (until == "" || dateStr <= until) {
|
||||
files = append(files, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Sink interface {
|
||||
Emit(event *Event) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type NopSink struct{}
|
||||
|
||||
func (NopSink) Emit(*Event) error { return nil }
|
||||
func (NopSink) Close() error { return nil }
|
||||
|
||||
type FileSink struct {
|
||||
writer *DateRotatingWriter
|
||||
chain *Chain
|
||||
forwarder *HTTPForwarder
|
||||
}
|
||||
|
||||
func NewFileSink(writer *DateRotatingWriter, chain *Chain, forwarder *HTTPForwarder) *FileSink {
|
||||
return &FileSink{
|
||||
writer: writer,
|
||||
chain: chain,
|
||||
forwarder: forwarder,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FileSink) Emit(evt *Event) error {
|
||||
body, err := marshalWithoutHash(evt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit: marshal event: %w", err)
|
||||
}
|
||||
|
||||
f, release, err := s.writer.beginAppend()
|
||||
if err != nil {
|
||||
return fmt.Errorf("audit: acquire writer: %w", err)
|
||||
}
|
||||
|
||||
// Derive prev_hash from the file tail, seal, and append — all under the
|
||||
// writer's process + inter-process lock so the chain cannot fork.
|
||||
prevHash, hash, err := s.chain.SealFromFile(f, body)
|
||||
if err != nil {
|
||||
release()
|
||||
return fmt.Errorf("audit: seal event: %w", err)
|
||||
}
|
||||
evt.PrevHash = prevHash
|
||||
evt.Hash = hash
|
||||
|
||||
line, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
release()
|
||||
return fmt.Errorf("audit: marshal final event: %w", err)
|
||||
}
|
||||
line = append(line, '\n')
|
||||
if _, err := f.Write(line); err != nil {
|
||||
release()
|
||||
return fmt.Errorf("audit: write event: %w", err)
|
||||
}
|
||||
release()
|
||||
|
||||
if s.forwarder != nil {
|
||||
s.forwarder.Forward(*evt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close flushes in-flight remote forwards (bounded) before closing the writer,
|
||||
// so events are not silently dropped when the CLI process exits right after
|
||||
// emitting.
|
||||
func (s *FileSink) Close() error {
|
||||
var forwardErr error
|
||||
if s.forwarder != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
forwardErr = s.forwarder.Close(ctx)
|
||||
cancel()
|
||||
}
|
||||
if err := s.writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return forwardErr
|
||||
}
|
||||
|
||||
func marshalWithoutHash(evt *Event) ([]byte, error) {
|
||||
saved := Event{
|
||||
Timestamp: evt.Timestamp,
|
||||
ExecutionID: evt.ExecutionID,
|
||||
AgentID: evt.AgentID,
|
||||
Actor: evt.Actor,
|
||||
Product: evt.Product,
|
||||
Command: evt.Command,
|
||||
Endpoint: evt.Endpoint,
|
||||
ParamsSummary: evt.ParamsSummary,
|
||||
Result: evt.Result,
|
||||
ErrCategory: evt.ErrCategory,
|
||||
ErrReason: evt.ErrReason,
|
||||
DurationMs: evt.DurationMs,
|
||||
CLIVersion: evt.CLIVersion,
|
||||
OS: evt.OS,
|
||||
Arch: evt.Arch,
|
||||
}
|
||||
return json.Marshal(saved)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CredentialSource identifies where a particular credential field
|
||||
// (ClientID or ClientSecret) was loaded from. It is exposed in
|
||||
// `dws event status` and the HelloAck IPC frame so users can verify which
|
||||
// credential channel is actually in use — important because env vars,
|
||||
// keychain, and config file can all coexist and silently override each
|
||||
// other (see plan §1 决策 "凭证来源拆字段").
|
||||
type CredentialSource string
|
||||
|
||||
const (
|
||||
CredentialSourceUnknown CredentialSource = "unknown"
|
||||
CredentialSourceEnv CredentialSource = "env"
|
||||
CredentialSourceAppConfig CredentialSource = "app_config" // value pulled from app config (plain or SecretRef metadata)
|
||||
CredentialSourceKeychain CredentialSource = "keychain" // SecretRef resolved through OS keychain
|
||||
CredentialSourcePlainConfig CredentialSource = "plain_config" // SecretInput stored as plaintext in config file (insecure but supported)
|
||||
)
|
||||
|
||||
// Strict resolver error sentinels. Use errors.Is to distinguish failure
|
||||
// modes; see plan §8 strict resolver decision (4 classes).
|
||||
var (
|
||||
// ErrAppConfigMissing — no app config file on disk AND no env-var
|
||||
// credentials present. Prompt the user to either `dws config init` or
|
||||
// set DWS_CLIENT_ID + DWS_CLIENT_SECRET.
|
||||
ErrAppConfigMissing = errors.New("app config missing: run `dws config init` or set DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars")
|
||||
// ErrClientIDEmpty — neither env nor config supplies a non-empty ClientID.
|
||||
ErrClientIDEmpty = errors.New("ClientID is empty")
|
||||
// ErrClientSecretEmpty — there's a ClientID but ClientSecret resolved to "".
|
||||
ErrClientSecretEmpty = errors.New("ClientSecret is empty")
|
||||
// ErrSecretResolve — the secret-resolution backend (keychain) failed
|
||||
// unrecoverably. Typically headless Linux without gnome-keyring, locked
|
||||
// macOS keychain, or CI sandboxes. Suggest the env-var fallback.
|
||||
ErrSecretResolve = errors.New("ClientSecret resolution failed (keychain unavailable?); try DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars")
|
||||
)
|
||||
|
||||
// Env var names used by the env fallback channel. Must be set as a pair —
|
||||
// any single-variable configuration is rejected so users cannot accidentally
|
||||
// "set the env half-way" and silently fall back to keychain.
|
||||
const (
|
||||
EnvClientID = "DWS_CLIENT_ID"
|
||||
EnvClientSecret = "DWS_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
// ResolveAppCredentialsStrict is the credentials channel used by the event
|
||||
// subsystem (and by future commands that need fine-grained failure
|
||||
// reporting). It distinguishes 4 failure classes and reports the source of
|
||||
// each successfully-resolved field separately.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Env var override: if BOTH DWS_CLIENT_ID and DWS_CLIENT_SECRET are
|
||||
// set non-empty, use them as a pair and skip keychain/config entirely.
|
||||
// Single-variable configuration is detected and reported via the
|
||||
// EnvHalfSet flag in the warning channel (callers MAY log a warning).
|
||||
// 2. App config from disk:
|
||||
// - ClientID from cfg.ClientID
|
||||
// - ClientSecret from ResolveSecret(cfg.ClientSecret):
|
||||
// - SecretInput.IsPlain() → CredentialSourcePlainConfig
|
||||
// - SecretRef → CredentialSourceKeychain (or whatever Ref.Source says)
|
||||
//
|
||||
// Empty returns: clientID and secret may be empty when err is non-nil;
|
||||
// callers must NOT use them in that case.
|
||||
func ResolveAppCredentialsStrict(configDir string) (
|
||||
clientID, secret string,
|
||||
clientIDSource, secretSource CredentialSource,
|
||||
err error,
|
||||
) {
|
||||
// Step 1: env var fallback (atomic pair)
|
||||
envID := os.Getenv(EnvClientID)
|
||||
envSecret := os.Getenv(EnvClientSecret)
|
||||
if envID != "" && envSecret != "" {
|
||||
return envID, envSecret, CredentialSourceEnv, CredentialSourceEnv, nil
|
||||
}
|
||||
// Note: if only one of the two is set we explicitly do NOT use it.
|
||||
// The half-set warning is surfaced via EnvHalfSet() so the CLI can
|
||||
// stderr-warn the user during preflight.
|
||||
|
||||
// Step 2: app config from disk
|
||||
cfg, loadErr := LoadAppConfig(configDir)
|
||||
if loadErr != nil {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown,
|
||||
fmt.Errorf("load app config: %w", loadErr)
|
||||
}
|
||||
if cfg == nil {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrAppConfigMissing
|
||||
}
|
||||
|
||||
if cfg.ClientID == "" {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrClientIDEmpty
|
||||
}
|
||||
clientID = cfg.ClientID
|
||||
clientIDSource = CredentialSourceAppConfig
|
||||
|
||||
// Resolve secret. Source depends on the SecretInput shape:
|
||||
// - IsPlain (no Ref) → it's stored as plaintext in the config file
|
||||
// - has Ref → it's a SecretRef pointing at keychain/file
|
||||
wasPlain := cfg.ClientSecret.IsPlain()
|
||||
resolved, resolveErr := ResolveSecret(cfg.ClientSecret)
|
||||
if resolveErr != nil {
|
||||
return "", "", CredentialSourceUnknown, CredentialSourceUnknown,
|
||||
fmt.Errorf("%w: %v", ErrSecretResolve, resolveErr)
|
||||
}
|
||||
if resolved == "" {
|
||||
return "", "", clientIDSource, CredentialSourceUnknown, ErrClientSecretEmpty
|
||||
}
|
||||
|
||||
secret = resolved
|
||||
if wasPlain {
|
||||
secretSource = CredentialSourcePlainConfig
|
||||
} else {
|
||||
// For SecretRef we map Source verbatim (keychain / file / future)
|
||||
switch cfg.ClientSecret.Ref.Source {
|
||||
case "keychain":
|
||||
secretSource = CredentialSourceKeychain
|
||||
default:
|
||||
// File-backed secrets share the "plain_config" category from
|
||||
// the consumer's perspective: stored as readable bytes outside
|
||||
// keychain. Status output renders them as "plain_config" so
|
||||
// users see "secret is not in keychain".
|
||||
secretSource = CredentialSourcePlainConfig
|
||||
}
|
||||
}
|
||||
|
||||
return clientID, secret, clientIDSource, secretSource, nil
|
||||
}
|
||||
|
||||
// EnvHalfSet reports whether exactly one of (DWS_CLIENT_ID, DWS_CLIENT_SECRET)
|
||||
// is set. Used by CLI preflight to emit a clear stderr warning of the form:
|
||||
//
|
||||
// WARN: DWS_CLIENT_ID is set but DWS_CLIENT_SECRET is not — env fallback
|
||||
// disabled; using keychain/app config. Set both or unset both to
|
||||
// avoid this warning.
|
||||
//
|
||||
// The strict resolver itself does NOT log; logging is the caller's job.
|
||||
func EnvHalfSet() bool {
|
||||
id := os.Getenv(EnvClientID) != ""
|
||||
secret := os.Getenv(EnvClientSecret) != ""
|
||||
return id != secret
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// resetStrictResolverState clears caches the strict resolver shares with
|
||||
// the existing legacy resolver. Tests must call this between scenarios
|
||||
// because GetCachedAppConfig and the resolved-credential cache outlive
|
||||
// individual t.TempDir setups.
|
||||
func resetStrictResolverState(t *testing.T) {
|
||||
t.Helper()
|
||||
cachedAppConfigMu.Lock()
|
||||
cachedAppConfig = nil
|
||||
cachedAppConfigMu.Unlock()
|
||||
cachedResolvedMu.Lock()
|
||||
cachedResolvedValid = false
|
||||
cachedResolvedID = ""
|
||||
cachedResolvedSecret = ""
|
||||
cachedResolvedMu.Unlock()
|
||||
}
|
||||
|
||||
// writeAppConfig drops a config JSON into dir. clientSecret == "" produces
|
||||
// the legacy "no SecretInput field" shape (treated as empty).
|
||||
func writeAppConfig(t *testing.T, dir, clientID, clientSecret string) {
|
||||
t.Helper()
|
||||
cfg := AppConfig{
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if clientSecret != "" {
|
||||
cfg.ClientSecret = PlainSecret(clientSecret)
|
||||
}
|
||||
path := GetAppConfigPath(dir)
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func unsetEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(EnvClientID, "")
|
||||
t.Setenv(EnvClientSecret, "")
|
||||
_ = os.Unsetenv(EnvClientID)
|
||||
_ = os.Unsetenv(EnvClientSecret)
|
||||
}
|
||||
|
||||
func TestResolveStrict_AppConfigMissing(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
|
||||
if !errors.Is(err, ErrAppConfigMissing) {
|
||||
t.Fatalf("err = %v, want ErrAppConfigMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_ClientIDEmpty(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
writeAppConfig(t, dir, "", "some-secret")
|
||||
|
||||
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
|
||||
if !errors.Is(err, ErrClientIDEmpty) {
|
||||
t.Fatalf("err = %v, want ErrClientIDEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_ClientSecretEmpty(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
writeAppConfig(t, dir, "ding_abc", "")
|
||||
|
||||
_, _, _, _, err := ResolveAppCredentialsStrict(dir)
|
||||
if !errors.Is(err, ErrClientSecretEmpty) {
|
||||
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_PlainConfigSuccess(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
writeAppConfig(t, dir, "ding_abc", "supersecret123")
|
||||
|
||||
id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if id != "ding_abc" {
|
||||
t.Errorf("id = %q", id)
|
||||
}
|
||||
if secret != "supersecret123" {
|
||||
t.Errorf("secret = %q", secret)
|
||||
}
|
||||
if idSrc != CredentialSourceAppConfig {
|
||||
t.Errorf("idSrc = %s, want app_config", idSrc)
|
||||
}
|
||||
if secretSrc != CredentialSourcePlainConfig {
|
||||
t.Errorf("secretSrc = %s, want plain_config (PlainSecret was used)", secretSrc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStrict_SecretRefFileSuccess(t *testing.T) {
|
||||
resetStrictResolverState(t)
|
||||
unsetEnv(t)
|
||||
dir := t.TempDir()
|
||||
// Write secret file
|
||||
secretPath := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(secretPath, []byte("via-file-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Write app config with file SecretRef
|
||||
cfg := AppConfig{
|
||||
ClientID: "ding_abc",
|
||||
ClientSecret: SecretInput{
|
||||
Ref: &SecretRef{Source: "file", ID: secretPath},
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
b, _ := json.MarshalIndent(cfg, "", " ")
|
||||
if err := os.WriteFile(GetAppConfigPath(dir), b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if id != "ding_abc" || secret != "via-file-secret" {
|
||||
t.Errorf("id/secret = %q/%q", id, secret)
|
||||
}
|
||||
if idSrc != CredentialSourceAppConfig {
|
||||
t.Errorf("idSrc = %s", idSrc)
|
||||
}
|
||||
// File-backed secrets are reported as plain_config (not in keychain).
|
||||
if secretSrc != CredentialSourcePlainConfig {
|
||||
t.Errorf("secretSrc = %s, want plain_config for file-backed secret", secretSrc)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,10 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
|
||||
// MigrateKeychainToFileDEK serializes migration with profile/token updates so
|
||||
// refresh and login cannot rewrite an entry while its DEK backend is changing.
|
||||
func MigrateKeychainToFileDEK(configDir string, dryRun bool) (int, error) {
|
||||
var migrated int
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
var err error
|
||||
migrated, err = keychain.MigrateToFileDEK(keychain.Service, dryRun)
|
||||
return err
|
||||
})
|
||||
return migrated, err
|
||||
}
|
||||
@@ -15,12 +15,14 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,6 +30,9 @@ var (
|
||||
migrationDone bool
|
||||
)
|
||||
|
||||
// ErrTokenDataNotFound means the requested keychain slot does not exist.
|
||||
var ErrTokenDataNotFound = errors.New("token data not found")
|
||||
|
||||
// SaveTokenDataKeychain saves TokenData to the platform keychain.
|
||||
// This is the new secure storage method using random master key.
|
||||
func SaveTokenDataKeychain(data *TokenData) error {
|
||||
@@ -86,7 +91,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", err)
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no token data in keychain account %q", account)
|
||||
return nil, fmt.Errorf("%w in keychain account %q", ErrTokenDataNotFound, account)
|
||||
}
|
||||
|
||||
var data TokenData
|
||||
@@ -96,6 +101,72 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// preflightTokenPersistence verifies that every registered token slot can be
|
||||
// read before an OAuth login or exchange can target any profile.
|
||||
// A missing slot is safe (first login or a legacy fallback); any other error
|
||||
// stops the remote operation when existing ciphertext is already known to be
|
||||
// unreadable and therefore unsafe to update.
|
||||
func preflightTokenPersistence(configDir string) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load token profiles: %w", err)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(cfg.Profiles))
|
||||
for _, profile := range cfg.Profiles {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
if corpID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[corpID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[corpID] = struct{}{}
|
||||
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf(
|
||||
"profile token slot %q is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, remove only this profile with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
|
||||
TokenAccountForCorpID(corpID), corpID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := keychain.ValidateAuthTokenEntries(keychain.Service); err != nil {
|
||||
return fmt.Errorf(
|
||||
"auth token ciphertext inventory is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, use `dws auth reset` only when discarding all local profiles: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
|
||||
// An unrelated broken profile must not prevent the current profile from using
|
||||
// its still-valid credentials.
|
||||
func preflightTokenRefreshPersistence(data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
if data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
|
||||
func DeleteTokenDataKeychain() error {
|
||||
return keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
|
||||
@@ -27,10 +27,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Use MCP mode if clientID is from MCP server
|
||||
if IsClientIDFromMCP() {
|
||||
return p.exchangeCodeViaMCP(ctx, code)
|
||||
|
||||
@@ -110,6 +110,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
@@ -623,6 +626,9 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if !data.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("refresh_token 已过期")
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(data); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
|
||||
@@ -67,7 +67,11 @@ func PortableAuthTargetPopulated(configDir string) bool {
|
||||
|
||||
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
|
||||
func PortableAuthSourceReady() bool {
|
||||
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
|
||||
if !portableAuthSourcePopulated(keychain.StorageDir(keychain.Service)) {
|
||||
return false
|
||||
}
|
||||
_, err := LoadTokenDataKeychain()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func portableAuthSourcePopulated(keychainDir string) bool {
|
||||
@@ -93,7 +97,7 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
return fmt.Errorf("missing output writer")
|
||||
}
|
||||
if !PortableExportSupported() {
|
||||
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
|
||||
return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv)
|
||||
}
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
if _, err := os.Stat(keychainDir); err != nil {
|
||||
@@ -102,6 +106,9 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
if !portableAuthSourcePopulated(keychainDir) {
|
||||
return fmt.Errorf("auth token is not available for export; run dws auth login first")
|
||||
}
|
||||
if _, err := LoadTokenDataKeychain(); err != nil {
|
||||
return fmt.Errorf("auth token cannot be decrypted with the portable file DEK: %w", err)
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -187,7 +188,7 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
if strings.TrimSpace(profile) != "" || !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
// No explicit --profile: `selected` is the resolved current/primary
|
||||
@@ -197,6 +198,8 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
return legacy, nil
|
||||
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
|
||||
return nil, lerr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
type preflightRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f preflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func seedUnreadableTokenStorage(t *testing.T, configDir string, data *TokenData) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setPreflightTestCredentials(t *testing.T) {
|
||||
t.Helper()
|
||||
SetClientID("preflight-client-id")
|
||||
SetClientSecret("preflight-client-secret")
|
||||
resetClientIDFromMCP()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
}
|
||||
|
||||
func profileCiphertextPathForTest(corpID string) string {
|
||||
account := strings.ReplaceAll(TokenAccountForCorpID(corpID), ":", "_")
|
||||
return filepath.Join(keychain.StorageDir(keychain.Service), account+".enc")
|
||||
}
|
||||
|
||||
func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_fallback", "corp_fallback", "Fallback Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := DeleteTokenDataKeychainForCorpID(data.CorpID); err != nil {
|
||||
t.Fatalf("DeleteTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() with missing profile slot error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != data.AccessToken {
|
||||
t.Fatalf("fallback access token = %q, want %q", loaded.AccessToken, data.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_unreadable", "corp_unreadable", "Unreadable Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err == nil {
|
||||
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceAllowsEmptyStorageWithoutCreatingDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight created a DEK at %q; stat error = %v", dekPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_preflight", "corp_preflight", "Preflight Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
err := preflightTokenPersistence(configDir)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile token slot") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want unreadable profile slot", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dws auth logout --profile \""+data.CorpID+"\"") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want per-profile recovery hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
|
||||
|
||||
// Simulate interruption after the profile ciphertext rename but before
|
||||
// profiles.json is updated by saveTokenDataLocked.
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, profilesJSONFile)); !os.IsNotExist(err) {
|
||||
t.Fatalf("profiles.json stat error = %v, want missing metadata", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x6f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthExportRejectsCiphertextFromAnotherDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_portable", "", "")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if !PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = false before replacing DEK")
|
||||
}
|
||||
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
if PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = true for ciphertext from another DEK")
|
||||
}
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(configDir, &bundle); err == nil {
|
||||
t.Fatal("ExportPortableAuthBundle() error = nil for ciphertext from another DEK")
|
||||
}
|
||||
if bundle.Len() != 0 {
|
||||
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
dataA := testToken("at_a", "corp_a", "A Org")
|
||||
dataB := testToken("at_b", "corp_b", "B Org")
|
||||
if err := SaveTokenData(configDir, dataA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, dataB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(dataA.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
if err := preflightTokenRefreshPersistence(dataB); err != nil {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
|
||||
}
|
||||
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("Login() with valid B and unreadable A error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != dataB.AccessToken {
|
||||
t.Fatalf("Login() access token = %q, want %q", loaded.AccessToken, dataB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
|
||||
setPreflightTestCredentials(t)
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.NoBrowser = true
|
||||
_, err := provider.Login(ctx, force)
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_device", "corp_device", "Device Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "unexpected device code request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.SetBaseURL(server.URL)
|
||||
_, err := provider.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("device code requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_refresh", "corp_refresh", "Refresh Org")
|
||||
data.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
legacyPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
|
||||
if err := os.WriteFile(legacyPath, []byte("corrupt legacy ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(legacy ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected refresh request")
|
||||
})}
|
||||
_, err := provider.lockedRefresh(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("lockedRefresh() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("refresh HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`,
|
||||
)),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "user-new")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v", err)
|
||||
}
|
||||
if data.AccessToken != "new-access" || data.UserID != "user-new" {
|
||||
t.Fatalf("ExchangeAuthCode() data = %#v", data)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
+40
-26
@@ -15,13 +15,13 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
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/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -67,45 +67,59 @@ func NewMCPCommand(_ context.Context, _ CatalogLoader, _ executor.Runner, _ *pip
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewSchemaCommand returns a stub schema command since the canonical
|
||||
// catalog discovery has been removed.
|
||||
func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
|
||||
// NewSchemaCommand serves the versioned embedded typed contract. A malformed
|
||||
// release snapshot fails closed; falling back to the live Cobra tree would
|
||||
// hide a broken delivery artifact and reintroduce a second Schema data path.
|
||||
func NewSchemaCommand(_ CatalogLoader) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Short: "查看有限的本地 Schema(静态端点模式)",
|
||||
Long: `查看有限的本地 Schema 元数据。
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须先进入 reviewed Registry,并从同一内嵌 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
all, _ := cmd.Flags().GetBool("all")
|
||||
compact, _ := cmd.Flags().GetBool("compact")
|
||||
cliPath, _ := cmd.Flags().GetString("cli-path")
|
||||
cliPath = strings.TrimSpace(cliPath)
|
||||
if cliPath != "" && len(args) > 0 {
|
||||
return apperrors.NewValidation("--cli-path and positional argument are mutually exclusive")
|
||||
}
|
||||
if len(args) == 1 && strings.EqualFold(strings.TrimSpace(args[0]), "list") {
|
||||
args = nil
|
||||
}
|
||||
if all && (cliPath != "" || len(args) > 0) {
|
||||
return apperrors.NewValidation("--all cannot be combined with a schema path")
|
||||
}
|
||||
if cliPath != "" {
|
||||
if len(args) > 0 {
|
||||
return apperrors.NewValidation("--cli-path and positional argument are mutually exclusive")
|
||||
}
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees support.
|
||||
if len(args) > 0 && helperTools != nil {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return nil
|
||||
}
|
||||
if err := embeddedSchemaCatalogError(); err != nil {
|
||||
return fmt.Errorf("load embedded typed Schema registry: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), `{"kind":"schema","count":0,"products":[],"note":"static endpoint mode"}`)
|
||||
return nil
|
||||
var payload map[string]any
|
||||
var err error
|
||||
if all {
|
||||
payload, err = embeddedSchemaAllPayload()
|
||||
} else if len(args) == 0 {
|
||||
payload, err = embeddedSchemaOverviewPayload()
|
||||
} else {
|
||||
payload, err = embeddedSchemaPayload(args)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if compact {
|
||||
payload = stripSchemaPayloadCompact(payload)
|
||||
}
|
||||
return output.WriteFiltered(cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), payload, output.ResolveFields(cmd), output.ResolveJQ(cmd))
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)")
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -1,298 +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 cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// helperSchemaRoots are top-level command names whose subtrees are helper-only
|
||||
// (hard-coded cobra commands, not in the discovery catalog). `dws schema` still
|
||||
// answers for them, but unlike discovery products the schema CONTENT is fetched
|
||||
// LIVE from the helper's pinned MCP server (op-app) and rendered in the flat
|
||||
// gws-aligned shape — never synthesized from local cobra flags, never
|
||||
// hardcoded. The mapping from a leaf command to its MCP tool comes from the
|
||||
// `mcp-tool` cobra annotation set in internal/helpers/devapp.go.
|
||||
var helperSchemaRoots = map[string]bool{"dev": true}
|
||||
|
||||
// HelperToolSchema is the live op-app tool schema the renderer needs: the raw
|
||||
// MCP description plus the inputSchema's properties/required, exactly as the
|
||||
// server returned them (no local transformation of CONTENT).
|
||||
type HelperToolSchema struct {
|
||||
Name string
|
||||
Description string
|
||||
Properties map[string]any // MCP param name → property object {type,description,default?,...}
|
||||
Required []string // MCP param names that are required
|
||||
}
|
||||
|
||||
// HelperToolFetcher loads a helper MCP server's tools/list LIVE and returns
|
||||
// toolName→schema for the given source (e.g. "op-app" for dev app commands,
|
||||
// "devdoc" for dev doc commands). The schema command injects this so
|
||||
// dev_schema.go can resolve a command's MCP tool and render its real schema
|
||||
// without the cli package importing app/transport. Implementations should
|
||||
// cache per-source per-process so repeated `dws schema dev.*` only hit the
|
||||
// network once per source.
|
||||
type HelperToolFetcher func(ctx context.Context, source string) (map[string]HelperToolSchema, error)
|
||||
|
||||
// renderHelperSchema builds the `dws schema` payload for helper-only command
|
||||
// subtrees. Returns (payload, true) when the path targets a helper subtree (so
|
||||
// the caller skips catalog resolution); (nil, false) otherwise so the caller
|
||||
// falls back to the discovery catalog.
|
||||
//
|
||||
// Leaf commands render the gws-flat object {description, path, source,
|
||||
// parameters{<kebab>:{type,description,default?,required}}} with all CONTENT
|
||||
// pulled live from the MCP tool named by the command's `mcp-tool` annotation.
|
||||
// Group/root paths render a browse listing {path, commands:[...]} from the
|
||||
// cobra tree (no MCP needed).
|
||||
func renderHelperSchema(ctx context.Context, root *cobra.Command, rawPath string, fetch HelperToolFetcher) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !helperSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Find resolves to the deepest matching command and returns trailing tokens
|
||||
// it couldn't match as (sub)commands. Any non-flag leftover means a typo'd
|
||||
// or unknown subcommand — surface it with the closest group's children.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// A runnable leaf → emit its live MCP schema in gws-flat shape.
|
||||
// A group → browse its subcommands.
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
payload, err := helperLeafSchema(ctx, target, fetch)
|
||||
return payload, true, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// helperLeafSchema renders a single leaf command as the gws-flat object,
|
||||
// fetching its MCP tool schema live. The command must carry an `mcp-tool`
|
||||
// annotation; commands without one (e.g. `dev connect`, `dev doc search`) are
|
||||
// not devapp tools and get a clear, non-fatal explanation instead.
|
||||
func helperLeafSchema(ctx context.Context, cmd *cobra.Command, fetch HelperToolFetcher) (map[string]any, error) {
|
||||
toolName, source := "", ""
|
||||
if cmd.Annotations != nil {
|
||||
toolName = strings.TrimSpace(cmd.Annotations["mcp-tool"])
|
||||
source = strings.TrimSpace(cmd.Annotations["mcp-source"])
|
||||
}
|
||||
// Default source is op-app (dev app commands); dev doc commands annotate
|
||||
// mcp-source=devdoc to pull from the devdoc MCP server instead.
|
||||
if source == "" {
|
||||
source = "op-app"
|
||||
}
|
||||
path := helperCommandPath(cmd)
|
||||
if toolName == "" {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "no MCP tool bound to this command; schema is unavailable",
|
||||
}, nil
|
||||
}
|
||||
if fetch == nil {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "live MCP schema fetcher not configured",
|
||||
}, nil
|
||||
}
|
||||
|
||||
tools, err := fetch(ctx, source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s tool schemas: %w", source, err)
|
||||
}
|
||||
tool, ok := tools[toolName]
|
||||
if !ok {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": fmt.Sprintf("MCP tool %q not found in %s tools/list", toolName, source),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"description": tool.Description,
|
||||
"path": path,
|
||||
"source": "mcp:" + source,
|
||||
"parameters": helperFlatParameters(tool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helperFlatParameters projects an MCP tool's inputSchema into the gws-flat
|
||||
// per-parameter object. Keys are kebab-case of the MCP param name (== the CLI
|
||||
// flag); each value is {type, description, default?, required} with type mapped
|
||||
// to a JSON-type string, description verbatim from MCP, default only when MCP
|
||||
// provides one (stringified), and required inline (true iff the param is in the
|
||||
// tool's required[]).
|
||||
func helperFlatParameters(tool HelperToolSchema) map[string]any {
|
||||
required := make(map[string]bool, len(tool.Required))
|
||||
for _, r := range tool.Required {
|
||||
required[r] = true
|
||||
}
|
||||
|
||||
params := make(map[string]any, len(tool.Properties))
|
||||
for name, raw := range tool.Properties {
|
||||
prop, _ := raw.(map[string]any)
|
||||
entry := map[string]any{
|
||||
"type": mcpJSONType(prop),
|
||||
"description": mcpString(prop, "description"),
|
||||
"required": required[name],
|
||||
}
|
||||
if def, ok := mcpDefault(prop); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[kebabCase(name)] = entry
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// mcpJSONType normalizes the MCP property "type" to a JSON-type string. MCP
|
||||
// reports standard JSON Schema types; pass them through, defaulting to "string"
|
||||
// when absent/unknown so the contract is always populated.
|
||||
func mcpJSONType(prop map[string]any) string {
|
||||
t, _ := prop["type"].(string)
|
||||
switch t {
|
||||
case "string", "integer", "number", "boolean", "array", "object":
|
||||
return t
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// mcpString reads a string field from an MCP property object.
|
||||
func mcpString(prop map[string]any, key string) string {
|
||||
if prop == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := prop[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// mcpDefault returns the MCP-provided default, stringified, only when present.
|
||||
// gws renders default as a string; mirror that. Non-string JSON defaults
|
||||
// (numbers/bools) are formatted with %v so e.g. 0 → "0", true → "true".
|
||||
func mcpDefault(prop map[string]any) (string, bool) {
|
||||
if prop == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := prop["default"]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
switch tv := v.(type) {
|
||||
case string:
|
||||
return tv, true
|
||||
case float64:
|
||||
// JSON numbers decode to float64; render integers without a fraction.
|
||||
if tv == float64(int64(tv)) {
|
||||
return fmt.Sprintf("%d", int64(tv)), true
|
||||
}
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
default:
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
}
|
||||
}
|
||||
|
||||
// kebabCase converts an MCP camelCase param name to the CLI flag's kebab form,
|
||||
// matching how flags are registered in internal/helpers/devapp.go:
|
||||
//
|
||||
// eventCallbackUrl → event-callback-url
|
||||
// unifiedAppId → unified-app-id
|
||||
// disableSSLVerify → disable-ssl-verify
|
||||
//
|
||||
// A boundary is inserted before an uppercase letter that follows a lowercase
|
||||
// letter or digit, and before the final uppercase of a run that starts a new
|
||||
// lowercase word (so SSLVerify → ssl-verify, not s-s-l-verify).
|
||||
func kebabCase(name string) string {
|
||||
runes := []rune(name)
|
||||
var b strings.Builder
|
||||
for i, r := range runes {
|
||||
if unicode.IsUpper(r) {
|
||||
prevLowerOrDigit := i > 0 && (unicode.IsLower(runes[i-1]) || unicode.IsDigit(runes[i-1]))
|
||||
nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||
if i > 0 && (prevLowerOrDigit || nextLower) {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
// Collapse any accidental double dashes and trim, just in case the source
|
||||
// already contained separators.
|
||||
out := strings.ReplaceAll(b.String(), "_", "-")
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
return strings.Trim(out, "-")
|
||||
}
|
||||
|
||||
// helperSubcommands lists a group's runnable children for browse mode, sorted
|
||||
// by name for deterministic output.
|
||||
func helperSubcommands(cmd *cobra.Command) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.IsAvailableCommand() || sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"cli_path": helperCommandPath(sub),
|
||||
"description": strings.TrimSpace(sub.Short),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["cli_path"].(string) < out[j]["cli_path"].(string)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// helperCommandPath returns the space-joined path from root to cmd, e.g.
|
||||
// "dev app robot config".
|
||||
func helperCommandPath(cmd *cobra.Command) string {
|
||||
parts := []string{}
|
||||
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
|
||||
parts = append([]string{c.Name()}, parts...)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// firstNonFlag returns the first token that is not a flag (does not start with
|
||||
// "-"), or "" if there is none.
|
||||
func firstNonFlag(tokens []string) string {
|
||||
for _, t := range tokens {
|
||||
if t != "" && !strings.HasPrefix(t, "-") {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,254 +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 cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildHelperTestTree mirrors the shape of the real `dws dev` subtree closely
|
||||
// enough to exercise the live-schema renderer: a group and leaves carrying the
|
||||
// `mcp-tool` annotation that names the op-app tool to fetch.
|
||||
func buildHelperTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
|
||||
create := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建应用",
|
||||
Annotations: map[string]string{"mcp-tool": "create_dev_app"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
config := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "配置机器人",
|
||||
Annotations: map[string]string{"mcp-tool": "set_extension_robot_config"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
// A leaf without an mcp-tool annotation (e.g. dev connect / dev doc search).
|
||||
noTool := &cobra.Command{Use: "connect", Short: "无 MCP 工具", Run: func(*cobra.Command, []string) {}}
|
||||
|
||||
robot := &cobra.Command{Use: "robot", Short: "机器人能力"}
|
||||
robot.AddCommand(config)
|
||||
|
||||
app := &cobra.Command{Use: "app", Short: "应用"}
|
||||
app.AddCommand(create, robot)
|
||||
|
||||
dev := &cobra.Command{Use: "dev", Short: "开放平台开发者命令"}
|
||||
dev.AddCommand(app, noTool)
|
||||
|
||||
root.AddCommand(dev)
|
||||
return root
|
||||
}
|
||||
|
||||
// fakeFetcher returns a canned op-app tools/list so the renderer is exercised
|
||||
// without network. It mirrors the MCP shape: properties keyed by camelCase param
|
||||
// name, required[] listing the camelCase names.
|
||||
func fakeFetcher(tools map[string]HelperToolSchema) HelperToolFetcher {
|
||||
return func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return tools, nil
|
||||
}
|
||||
}
|
||||
|
||||
func robotConfigToolSchema() HelperToolSchema {
|
||||
return HelperToolSchema{
|
||||
Name: "set_extension_robot_config",
|
||||
Description: "创建或更新现有应用的机器人配置",
|
||||
Properties: map[string]any{
|
||||
"unifiedAppId": map[string]any{"type": "string", "description": "统一应用 ID"},
|
||||
"eventCallbackUrl": map[string]any{"type": "string", "description": "事件回调地址"},
|
||||
"skills": map[string]any{"type": "array", "description": "技能列表"},
|
||||
"mode": map[string]any{"type": "string", "description": "机器人模式", "enum": []any{"HTTPS", "STREAM", "AISKILL"}},
|
||||
},
|
||||
Required: []string{"unifiedAppId"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_LeafGwsFlat(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
fetch := fakeFetcher(map[string]HelperToolSchema{
|
||||
"set_extension_robot_config": robotConfigToolSchema(),
|
||||
})
|
||||
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app robot config", fetch)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected helper renderer to claim the path")
|
||||
}
|
||||
|
||||
// Flat top-level: description / path / source / parameters; no wrapper.
|
||||
if payload["description"] != "创建或更新现有应用的机器人配置" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "dev app robot config" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "mcp:op-app" {
|
||||
t.Fatalf("source = %v", payload["source"])
|
||||
}
|
||||
for _, leaked := range []string{"kind", "tool", "product", "helper"} {
|
||||
if _, present := payload[leaked]; present {
|
||||
t.Fatalf("gws-flat output must not carry %q wrapper key", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Keys are kebab-case of the MCP param name == the CLI flag.
|
||||
uid, _ := params["unified-app-id"].(map[string]any)
|
||||
if uid == nil {
|
||||
t.Fatalf("missing unified-app-id param: %#v", params)
|
||||
}
|
||||
if uid["type"] != "string" || uid["required"] != true {
|
||||
t.Fatalf("unified-app-id = %#v, want string+required", uid)
|
||||
}
|
||||
if _, hasDefault := uid["default"]; hasDefault {
|
||||
t.Fatal("unified-app-id must not carry a default (MCP provides none)")
|
||||
}
|
||||
|
||||
cb, _ := params["event-callback-url"].(map[string]any)
|
||||
if cb == nil || cb["required"] != false {
|
||||
t.Fatalf("event-callback-url = %#v, want required=false", cb)
|
||||
}
|
||||
|
||||
skills, _ := params["skills"].(map[string]any)
|
||||
if skills == nil || skills["type"] != "array" {
|
||||
t.Fatalf("skills = %#v, want array", skills)
|
||||
}
|
||||
|
||||
mode, _ := params["mode"].(map[string]any)
|
||||
if mode == nil || mode["type"] != "string" {
|
||||
t.Fatalf("mode = %#v, want string", mode)
|
||||
}
|
||||
if _, hasDefault := mode["default"]; hasDefault {
|
||||
t.Fatalf("mode default = %v, want none", mode["default"])
|
||||
}
|
||||
if mode["required"] != false {
|
||||
t.Fatalf("mode required = %v, want false", mode["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_Group(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["path"] != "dev app" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
if len(cmds) != 2 { // create + robot
|
||||
t.Fatalf("commands count = %d, want 2", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NoAnnotation(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev connect", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected a clear no-MCP-tool error, got %#v", payload)
|
||||
}
|
||||
if _, present := payload["parameters"]; present {
|
||||
t.Fatal("no-tool command must not emit parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app nope", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected error for unknown subcommand, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_ToolMissingInList(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
// Fetcher returns an empty list — the annotated tool isn't present.
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app create", fakeFetcher(map[string]HelperToolSchema{}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected not-found error, got %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_FetchError(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
failing := func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return nil, errors.New("network down")
|
||||
}
|
||||
_, ok, err := renderHelperSchema(context.Background(), root, "dev app create", failing)
|
||||
if !ok {
|
||||
t.Fatal("expected claim even on fetch error")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the fetch error to surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NonHelperPathDeclined(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
if _, ok, _ := renderHelperSchema(context.Background(), root, "ding.message.send", nil); ok {
|
||||
t.Fatal("non-helper path must not be claimed by the helper renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKebabCase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"eventCallbackUrl": "event-callback-url",
|
||||
"unifiedAppId": "unified-app-id",
|
||||
"disableSSLVerify": "disable-ssl-verify",
|
||||
"mode": "mode",
|
||||
"skills": "skills",
|
||||
"i18nName": "i18n-name",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := kebabCase(in); got != want {
|
||||
t.Errorf("kebabCase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestRuntimeFlagContractCoversPflagFamilies(t *testing.T) {
|
||||
flags := pflag.NewFlagSet("schema-types", pflag.ContinueOnError)
|
||||
flags.Uint("uint", 0, "")
|
||||
flags.Count("count", "")
|
||||
flags.Float64("ratio", 0, "")
|
||||
flags.IntSlice("ids", nil, "")
|
||||
flags.DurationSlice("windows", nil, "")
|
||||
flags.Duration("timeout", 0, "")
|
||||
flags.StringToString("labels", nil, "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantType string
|
||||
wantDefault string
|
||||
}{
|
||||
{name: "uint", wantType: "integer"},
|
||||
{name: "count", wantType: "integer"},
|
||||
{name: "ratio", wantType: "number"},
|
||||
{name: "ids", wantType: "array"},
|
||||
{name: "windows", wantType: "array"},
|
||||
{name: "timeout", wantType: "string"},
|
||||
{name: "labels", wantType: "string"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
flag := flags.Lookup(test.name)
|
||||
if got := runtimeFlagCLIType(flag); got != test.wantType {
|
||||
t.Errorf("--%s type = %q, want %q", test.name, got, test.wantType)
|
||||
}
|
||||
if got := runtimeFlagDefault(flag); got != test.wantDefault {
|
||||
t.Errorf("--%s default = %q, want %q", test.name, got, test.wantDefault)
|
||||
}
|
||||
}
|
||||
|
||||
flags.Duration("nonzero-timeout", 5*time.Second, "")
|
||||
if got := runtimeFlagDefault(flags.Lookup("nonzero-timeout")); got != "5s" {
|
||||
t.Fatalf("non-zero duration default = %q, want 5s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry.json -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
|
||||
// Rebuild all dependencies so the Catalog compiler cannot reuse the cli
|
||||
// package cached by the preceding metadata generator with the old embedded
|
||||
// JSON files.
|
||||
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog.json
|
||||
|
||||
//go:embed schema_agent_metadata/*.json
|
||||
var embeddedAgentMetadataFS embed.FS
|
||||
|
||||
const embeddedAgentMetadataSource = "embedded-skill-metadata"
|
||||
|
||||
type embeddedAgentMetadata struct {
|
||||
Version int `json:"version"`
|
||||
SourceHash string `json:"source_hash"`
|
||||
SurfaceHash string `json:"surface_hash,omitempty"`
|
||||
Coverage embeddedAgentMetadataCoverage `json:"coverage"`
|
||||
Products map[string]agentProductMetadata `json:"products"`
|
||||
Domains []string `json:"domains"`
|
||||
Tools map[string]agentToolMetadata `json:"tools"`
|
||||
}
|
||||
|
||||
type embeddedAgentMetadataCoverage struct {
|
||||
SurfaceProducts int `json:"surface_products,omitempty"`
|
||||
ProductsWithMetadata int `json:"products_with_metadata"`
|
||||
SurfaceTools int `json:"surface_tools,omitempty"`
|
||||
ToolsWithMetadata int `json:"tools_with_metadata"`
|
||||
ToolsWithSummary int `json:"tools_with_agent_summary,omitempty"`
|
||||
ToolsWithUseWhen int `json:"tools_with_use_when,omitempty"`
|
||||
ToolsWithAvoidWhen int `json:"tools_with_avoid_when,omitempty"`
|
||||
ToolsWithExamples int `json:"tools_with_examples,omitempty"`
|
||||
ToolsWithInterfaceMode int `json:"tools_with_interface_mode,omitempty"`
|
||||
UnmatchedSkillTools int `json:"unmatched_skill_tools,omitempty"`
|
||||
UnreviewedSkillTools int `json:"unreviewed_skill_tools,omitempty"`
|
||||
}
|
||||
|
||||
type agentProductMetadata struct {
|
||||
AgentSummary string `json:"agent_summary,omitempty"`
|
||||
AgentSummarySource string `json:"agent_summary_source,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
SourceRefs []string `json:"source_refs,omitempty"`
|
||||
FieldProvenance map[string]FieldProvenance `json:"field_provenance,omitempty"`
|
||||
}
|
||||
|
||||
type agentToolMetadata struct {
|
||||
AgentSummary string `json:"agent_summary,omitempty"`
|
||||
AgentSummarySource string `json:"agent_summary_source,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Tips []string `json:"tips,omitempty"`
|
||||
Effect string `json:"effect,omitempty"`
|
||||
EffectSource string `json:"effect_source,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
Confirmation string `json:"confirmation,omitempty"`
|
||||
Idempotency string `json:"idempotency,omitempty"`
|
||||
WorkflowRefs []string `json:"workflow_refs,omitempty"`
|
||||
Examples []string `json:"examples,omitempty"`
|
||||
Reviewed *bool `json:"reviewed,omitempty"`
|
||||
SourceRefs []string `json:"source_refs,omitempty"`
|
||||
InterfaceRef *embeddedMCPInterfaceRef `json:"interface_ref,omitempty"`
|
||||
InterfaceMode string `json:"interface_mode,omitempty"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
InterfaceReason string `json:"interface_reason,omitempty"`
|
||||
FieldProvenance map[string]FieldProvenance `json:"field_provenance,omitempty"`
|
||||
}
|
||||
|
||||
type embeddedAgentMetadataDomain struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Tools map[string]agentToolMetadata `json:"tools"`
|
||||
}
|
||||
|
||||
var runtimeEmbeddedAgentMetadataLazy struct {
|
||||
once sync.Once
|
||||
metadata embeddedAgentMetadata
|
||||
}
|
||||
|
||||
var runtimeEmbeddedAgentMetadataLazyLoadCount atomic.Uint64
|
||||
|
||||
// runtimeAgentMetadata parses the generated Agent metadata on first Schema
|
||||
// assembly only. Keeping the sync.Once at the access boundary ensures package
|
||||
// initialization and ordinary CLI commands never deserialize the embedded
|
||||
// fragments.
|
||||
func runtimeAgentMetadata() embeddedAgentMetadata {
|
||||
runtimeEmbeddedAgentMetadataLazy.once.Do(func() {
|
||||
runtimeEmbeddedAgentMetadataLazyLoadCount.Add(1)
|
||||
runtimeEmbeddedAgentMetadataLazy.metadata = loadEmbeddedAgentMetadata()
|
||||
})
|
||||
return runtimeEmbeddedAgentMetadataLazy.metadata
|
||||
}
|
||||
|
||||
func loadEmbeddedAgentMetadata() embeddedAgentMetadata {
|
||||
var metadata embeddedAgentMetadata
|
||||
index, err := embeddedAgentMetadataFS.ReadFile("schema_agent_metadata/index.json")
|
||||
if err != nil || json.Unmarshal(index, &metadata) != nil {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
metadata.Tools = map[string]agentToolMetadata{}
|
||||
for _, domain := range metadata.Domains {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if domain == "" || strings.Contains(domain, "/") || strings.Contains(domain, "\\") {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
data, err := embeddedAgentMetadataFS.ReadFile("schema_agent_metadata/" + domain + ".json")
|
||||
if err != nil {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
var fragment embeddedAgentMetadataDomain
|
||||
if err := json.Unmarshal(data, &fragment); err != nil || strings.TrimSpace(fragment.ProductID) != domain {
|
||||
return emptyEmbeddedAgentMetadata()
|
||||
}
|
||||
for path, tool := range fragment.Tools {
|
||||
metadata.Tools[path] = tool
|
||||
}
|
||||
}
|
||||
if metadata.Products == nil {
|
||||
metadata.Products = map[string]agentProductMetadata{}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func emptyEmbeddedAgentMetadata() embeddedAgentMetadata {
|
||||
return embeddedAgentMetadata{
|
||||
Products: map[string]agentProductMetadata{},
|
||||
Tools: map[string]agentToolMetadata{},
|
||||
}
|
||||
}
|
||||
|
||||
// agentToolContractForPathsFromMetadata is the sole typed adapter from generated Agent
|
||||
// metadata to runtime contract assembly. Path resolution happens once; all
|
||||
// consumers receive the same resolved safety, interface, selection and
|
||||
// provenance values without performing downstream map merges.
|
||||
func agentToolContractForPathsFromMetadata(source embeddedAgentMetadata, paths ...string) (SafetySpec, InterfaceSpec, SelectionSpec, map[string]FieldProvenance, bool) {
|
||||
metadata, ok := lookupAgentToolMetadataFrom(source, paths...)
|
||||
if !ok {
|
||||
return SafetySpec{}, InterfaceSpec{}, SelectionSpec{}, nil, false
|
||||
}
|
||||
safety := SafetySpec{
|
||||
Effect: strings.TrimSpace(metadata.Effect),
|
||||
EffectSource: strings.TrimSpace(metadata.EffectSource),
|
||||
Risk: strings.TrimSpace(metadata.Risk),
|
||||
Confirmation: strings.TrimSpace(metadata.Confirmation),
|
||||
Idempotency: strings.TrimSpace(metadata.Idempotency),
|
||||
}
|
||||
interfaceSpec := InterfaceSpec{
|
||||
Mode: strings.TrimSpace(metadata.InterfaceMode),
|
||||
Availability: strings.TrimSpace(metadata.Availability),
|
||||
Reason: strings.TrimSpace(metadata.InterfaceReason),
|
||||
}
|
||||
if metadata.InterfaceRef != nil {
|
||||
interfaceSpec.Ref = &InterfaceRefSpec{
|
||||
ProductID: strings.TrimSpace(metadata.InterfaceRef.ProductID),
|
||||
RPCName: strings.TrimSpace(metadata.InterfaceRef.RPCName),
|
||||
}
|
||||
}
|
||||
selection := agentToolSelection(metadata)
|
||||
provenance := resolvedAgentToolProvenance(metadata.FieldProvenance, interfaceSpec, selection)
|
||||
return safety, interfaceSpec, selection, provenance, true
|
||||
}
|
||||
|
||||
// resolvedAgentToolProvenance is the typed source adapter for generated Agent
|
||||
// metadata. The generator stores concrete interface refs in its compact
|
||||
// "product.rpc" identity form and stores reviewed no-ref dispositions as JSON
|
||||
// null. The final typed contract stores an object or JSON null, so project the
|
||||
// concrete compact identity here. Missing provenance is never synthesized:
|
||||
// constructors and snapshot loaders remain validate-only and fail closed.
|
||||
func resolvedAgentToolProvenance(source map[string]FieldProvenance, interfaceSpec InterfaceSpec, selection SelectionSpec) map[string]FieldProvenance {
|
||||
out := cloneFieldProvenance(source)
|
||||
if provenance, ok := out["interface_ref"]; ok {
|
||||
out["interface_ref"] = projectAgentInterfaceRefProvenance(provenance, interfaceSpec.Ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectAgentInterfaceRefProvenance(provenance FieldProvenance, ref *InterfaceRefSpec) FieldProvenance {
|
||||
finalValue, _ := json.Marshal(ref)
|
||||
legacy := "<none>"
|
||||
if ref != nil {
|
||||
legacy = strings.TrimSpace(ref.ProductID) + "." + strings.TrimSpace(ref.RPCName)
|
||||
}
|
||||
legacyValue, _ := json.Marshal(legacy)
|
||||
project := func(value json.RawMessage) json.RawMessage {
|
||||
if string(value) == string(legacyValue) || string(value) == string(finalValue) {
|
||||
return append(json.RawMessage(nil), finalValue...)
|
||||
}
|
||||
// Keep a disagreeing source value untouched. ToolSpec validation will
|
||||
// then fail instead of this adapter laundering a resolver conflict.
|
||||
return value
|
||||
}
|
||||
provenance.Value = project(provenance.Value)
|
||||
for index := range provenance.Candidates {
|
||||
candidate := &provenance.Candidates[index]
|
||||
if candidate.Selected != nil && *candidate.Selected {
|
||||
candidate.Value = project(candidate.Value)
|
||||
}
|
||||
}
|
||||
return provenance
|
||||
}
|
||||
|
||||
func resolvedFieldProvenance(value any, source, sourceRef, precedence, resolution, reviewReason string) FieldProvenance {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
raw = json.RawMessage("null")
|
||||
}
|
||||
selected := true
|
||||
return FieldProvenance{
|
||||
Value: append(json.RawMessage(nil), raw...),
|
||||
Source: strings.TrimSpace(source),
|
||||
SourceRef: strings.TrimSpace(sourceRef),
|
||||
Precedence: strings.TrimSpace(precedence),
|
||||
Resolution: strings.TrimSpace(resolution),
|
||||
ReviewReason: strings.TrimSpace(reviewReason),
|
||||
Candidates: []FieldCandidateProvenance{{
|
||||
Value: append(json.RawMessage(nil), raw...),
|
||||
Source: strings.TrimSpace(source),
|
||||
SourceRef: strings.TrimSpace(sourceRef),
|
||||
Precedence: strings.TrimSpace(precedence),
|
||||
ReviewReason: strings.TrimSpace(reviewReason),
|
||||
Selected: &selected,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// agentProductSelectionForIDsFromMetadata exposes generated product routing
|
||||
// through the same typed SelectionSpec used by ToolSpec.
|
||||
func agentProductSelectionForIDsFromMetadata(source embeddedAgentMetadata, ids ...string) (SelectionSpec, bool) {
|
||||
selection, _, ok := agentProductContractForIDsFromMetadata(source, ids...)
|
||||
return selection, ok
|
||||
}
|
||||
|
||||
func agentProductContractForIDsFromMetadata(source embeddedAgentMetadata, ids ...string) (SelectionSpec, map[string]FieldProvenance, bool) {
|
||||
for _, id := range ids {
|
||||
metadata, ok := source.Products[strings.TrimSpace(id)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
selection := SelectionSpec{
|
||||
AgentSummary: strings.TrimSpace(metadata.AgentSummary),
|
||||
AgentSummarySource: strings.TrimSpace(metadata.AgentSummarySource),
|
||||
UseWhen: cloneOptionalStrings(metadata.UseWhen),
|
||||
AvoidWhen: cloneOptionalStrings(metadata.AvoidWhen),
|
||||
SourceRefs: cloneOptionalStrings(metadata.SourceRefs),
|
||||
MetadataSource: embeddedAgentMetadataSource,
|
||||
}.normalized()
|
||||
return selection, cloneFieldProvenance(metadata.FieldProvenance), true
|
||||
}
|
||||
return SelectionSpec{}, nil, false
|
||||
}
|
||||
|
||||
func agentToolSelection(metadata agentToolMetadata) SelectionSpec {
|
||||
var reviewed *bool
|
||||
if metadata.Reviewed != nil {
|
||||
value := *metadata.Reviewed
|
||||
reviewed = &value
|
||||
}
|
||||
return SelectionSpec{
|
||||
AgentSummary: strings.TrimSpace(metadata.AgentSummary),
|
||||
AgentSummarySource: strings.TrimSpace(metadata.AgentSummarySource),
|
||||
UseWhen: cloneOptionalStrings(metadata.UseWhen),
|
||||
AvoidWhen: cloneOptionalStrings(metadata.AvoidWhen),
|
||||
Prerequisites: cloneOptionalStrings(metadata.Prerequisites),
|
||||
Tips: cloneOptionalStrings(metadata.Tips),
|
||||
WorkflowRefs: cloneOptionalStrings(metadata.WorkflowRefs),
|
||||
Examples: cloneOptionalStrings(metadata.Examples),
|
||||
Reviewed: reviewed,
|
||||
SourceRefs: cloneOptionalStrings(metadata.SourceRefs),
|
||||
MetadataSource: embeddedAgentMetadataSource,
|
||||
}.normalized()
|
||||
}
|
||||
|
||||
func cloneFieldProvenance(source map[string]FieldProvenance) map[string]FieldProvenance {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]FieldProvenance, len(source))
|
||||
for field, provenance := range source {
|
||||
provenance.Value = append(json.RawMessage(nil), provenance.Value...)
|
||||
provenance.Candidates = cloneFieldCandidates(provenance.Candidates)
|
||||
provenance.OverriddenCandidates = cloneFieldCandidates(provenance.OverriddenCandidates)
|
||||
out[field] = provenance
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneFieldCandidates(source []FieldCandidateProvenance) []FieldCandidateProvenance {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]FieldCandidateProvenance, len(source))
|
||||
for index, candidate := range source {
|
||||
candidate.Value = append(json.RawMessage(nil), candidate.Value...)
|
||||
if candidate.Selected != nil {
|
||||
value := *candidate.Selected
|
||||
candidate.Selected = &value
|
||||
}
|
||||
out[index] = candidate
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupAgentToolMetadataFrom(source embeddedAgentMetadata, paths ...string) (agentToolMetadata, bool) {
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
for _, candidate := range []string{
|
||||
strings.TrimSpace(path),
|
||||
strings.Join(splitSchemaPathTokens(path), " "),
|
||||
} {
|
||||
if candidate == "" || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
if metadata, ok := source.Tools[candidate]; ok {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return agentToolMetadata{}, false
|
||||
}
|
||||
|
||||
func agentMetadataSummaryFrom(metadata embeddedAgentMetadata) map[string]any {
|
||||
summary := map[string]any{
|
||||
"source": embeddedAgentMetadataSource,
|
||||
"version": metadata.Version,
|
||||
"source_hash": strings.TrimSpace(metadata.SourceHash),
|
||||
"products_with_metadata": len(metadata.Products),
|
||||
"tools_with_metadata": len(metadata.Tools),
|
||||
}
|
||||
if metadata.SurfaceHash != "" {
|
||||
summary["surface_hash"] = metadata.SurfaceHash
|
||||
}
|
||||
coverage := metadata.Coverage
|
||||
if coverage.SurfaceProducts > 0 {
|
||||
summary["surface_products"] = coverage.SurfaceProducts
|
||||
}
|
||||
if coverage.SurfaceTools > 0 {
|
||||
summary["surface_tools"] = coverage.SurfaceTools
|
||||
}
|
||||
if coverage.ToolsWithSummary > 0 {
|
||||
summary["tools_with_agent_summary"] = coverage.ToolsWithSummary
|
||||
}
|
||||
if coverage.UnmatchedSkillTools > 0 {
|
||||
summary["unmatched_skill_tools"] = coverage.UnmatchedSkillTools
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
{
|
||||
"product_id": "aisearch",
|
||||
"tools": {
|
||||
"aisearch behavior": {
|
||||
"agent_summary": "搜索发送/创建/分享/编辑/接收等明确行为记录",
|
||||
"agent_summary_source": "dws-agent-selection/aisearch",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只按主题找内容本身时用 aisearch enterprise",
|
||||
"没有行为动作词时不要选用本工具"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws aisearch behavior --types mail --behavior-type send --direction \"我-\u003e汐峰\" --format json",
|
||||
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "搜索发送/创建/分享/编辑/接收等明确行为记录",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "搜索发送/创建/分享/编辑/接收等明确行为记录",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
|
||||
},
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"只按主题找内容本身时用 aisearch enterprise",
|
||||
"没有行为动作词时不要选用本工具"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"只按主题找内容本身时用 aisearch enterprise",
|
||||
"没有行为动作词时不要选用本工具"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws aisearch behavior --types mail --behavior-type send --direction \"我-\u003e汐峰\" --format json",
|
||||
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws aisearch behavior --types mail --behavior-type send --direction \"我-\u003e汐峰\" --format json",
|
||||
"dws aisearch behavior --queries \"智能化方案\" --types document --behavior-type create --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
|
||||
},
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": "aisearch.search_enterprise_behavior",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "aisearch.search_enterprise_behavior",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "aisearch",
|
||||
"rpc_name": "search_enterprise_behavior"
|
||||
},
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"Cobra help: dws aisearch behavior --help",
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_command_registry.json#aisearch.search_enterprise_behavior",
|
||||
"internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"internal/cli/schema_hints/selection/aisearch.json",
|
||||
"internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise_behavior",
|
||||
"live-dws-schema:aisearch.search_enterprise_behavior",
|
||||
"skills/mono/references/intent-guide.md",
|
||||
"skills/mono/references/products/aisearch.md"
|
||||
],
|
||||
"use_when": [
|
||||
"用户明确问我/某人发过、发给、收到、创建、分享、编辑过什么"
|
||||
]
|
||||
},
|
||||
"aisearch enterprise": {
|
||||
"agent_summary": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
|
||||
"agent_summary_source": "dws-agent-selection/aisearch",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
|
||||
"企业找人时用 aisearch person",
|
||||
"已知具体资源 ID 要读写时改用对应产品命令"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
|
||||
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "搜索企业内部知识与相关内容(文档/IM/日历/待办/纪要/日志/邮件等)",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
|
||||
},
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
|
||||
"企业找人时用 aisearch person",
|
||||
"已知具体资源 ID 要读写时改用对应产品命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"问“我发给谁/谁发给我/我创建过”等行为追溯时用 aisearch behavior",
|
||||
"企业找人时用 aisearch person",
|
||||
"已知具体资源 ID 要读写时改用对应产品命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
|
||||
},
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
|
||||
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws aisearch enterprise --queries \"智能化方案\" --types document --format json",
|
||||
"dws aisearch enterprise --queries \"OKR\" --types mail --time-range \"最近\" --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
|
||||
},
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": "aisearch.search_enterprise",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "aisearch.search_enterprise",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The reviewed CLI supports an all-content search with default --types all, while --queries and --time-range are optional filters; imported MCP required markers must not turn them into mandatory CLI input."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
|
||||
"用户显式给出时间词或类型词时分别映射到 --time-range / --types"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
|
||||
"用户显式给出时间词或类型词时分别映射到 --time-range / --types"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "aisearch",
|
||||
"rpc_name": "search_enterprise"
|
||||
},
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"Cobra help: dws aisearch enterprise --help",
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_command_registry.json#aisearch.search_enterprise",
|
||||
"internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"internal/cli/schema_hints/selection/aisearch.json",
|
||||
"internal/cli/schema_mcp_metadata.json#tools.aisearch.search_enterprise",
|
||||
"live-dws-schema:aisearch.search_enterprise",
|
||||
"skills/mono/references/intent-guide.md",
|
||||
"skills/mono/references/products/aisearch.md"
|
||||
],
|
||||
"use_when": [
|
||||
"按主题找资料、方案、文档、消息、邮件等内容,且关注“有什么内容”",
|
||||
"用户显式给出时间词或类型词时分别映射到 --time-range / --types"
|
||||
]
|
||||
},
|
||||
"aisearch person": {
|
||||
"agent_summary": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
|
||||
"agent_summary_source": "dws-agent-selection/aisearch",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"已有 userId 只需详情时用 contact user get",
|
||||
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws aisearch person --keyword \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "企业内找人:按姓名/部门/职位/职责/上下级/手机号/工号筛选",
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
|
||||
},
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"已有 userId 只需详情时用 contact user get",
|
||||
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已有 userId 只需详情时用 contact user get",
|
||||
"精确通讯录关键词搜同事/好友且不涉及职责语义时可用 contact user search",
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws aisearch person --keyword \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws aisearch person --keyword \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
|
||||
},
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": "aisearch.enterprise_person_search",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "aisearch.enterprise_person_search",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/aisearch marks this tool as reviewed"
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
|
||||
"需要把维度词映射到 --dimension,关键词只保留目标实体"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
|
||||
"需要把维度词映射到 --dimension,关键词只保留目标实体"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aisearch.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "aisearch",
|
||||
"rpc_name": "enterprise_person_search"
|
||||
},
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"Cobra help: dws aisearch person --help",
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_command_registry.json#aisearch.enterprise_person_search",
|
||||
"internal/cli/schema_hints/metadata/aisearch.json",
|
||||
"internal/cli/schema_hints/selection/aisearch.json",
|
||||
"internal/cli/schema_mcp_metadata.json#tools.aisearch.enterprise_person_search",
|
||||
"live-dws-schema:aisearch.enterprise_person_search",
|
||||
"skills/mono/references/intent-guide.md",
|
||||
"skills/mono/references/products/aisearch.md",
|
||||
"skills/mono/references/products/mail.md"
|
||||
],
|
||||
"use_when": [
|
||||
"找人、谁负责某事、查上级/下级、按手机号或工号定位人员",
|
||||
"需要把维度词映射到 --dimension,关键词只保留目标实体"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,794 @@
|
||||
{
|
||||
"product_id": "audit",
|
||||
"tools": {
|
||||
"audit export": {
|
||||
"agent_summary": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
"agent_summary_source": "dws-agent-selection/audit",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只看最近几条用 audit tail",
|
||||
"只校验哈希链完整性用 audit verify"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws audit export --format jsonl",
|
||||
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"只看最近几条用 audit tail",
|
||||
"只校验哈希链完整性用 audit verify"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"只看最近几条用 audit tail",
|
||||
"只校验哈希链完整性用 audit verify"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws audit export --format jsonl",
|
||||
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws audit export --format jsonl",
|
||||
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log export from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log export from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读导出,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "local",
|
||||
"interface_reason": "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"cobra-help:dws audit export --help",
|
||||
"internal/app/audit_command.go",
|
||||
"internal/cli/schema_command_registry.json#audit.export",
|
||||
"internal/cli/schema_hints/metadata/audit.json",
|
||||
"internal/cli/schema_hints/selection/audit.json"
|
||||
],
|
||||
"use_when": [
|
||||
"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"
|
||||
]
|
||||
},
|
||||
"audit tail": {
|
||||
"agent_summary": "查看本地操作审计日志最近 N 条记录",
|
||||
"agent_summary_source": "dws-agent-selection/audit",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"需要按日期范围整段导出用 audit export",
|
||||
"需要校验哈希链用 audit verify"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws audit tail",
|
||||
"dws audit tail --lines 50"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "查看本地操作审计日志最近 N 条记录",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "查看本地操作审计日志最近 N 条记录",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"需要按日期范围整段导出用 audit export",
|
||||
"需要校验哈希链用 audit verify"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要按日期范围整段导出用 audit export",
|
||||
"需要校验哈希链用 audit verify"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws audit tail",
|
||||
"dws audit tail --lines 50"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws audit tail",
|
||||
"dws audit tail --lines 50"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit log tail from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"需要快速查看最近写入的审计记录(默认最近 20 条)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"需要快速查看最近写入的审计记录(默认最近 20 条)"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读查看,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "local",
|
||||
"interface_reason": "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"cobra-help:dws audit tail --help",
|
||||
"internal/app/audit_command.go",
|
||||
"internal/cli/schema_command_registry.json#audit.tail",
|
||||
"internal/cli/schema_hints/metadata/audit.json",
|
||||
"internal/cli/schema_hints/selection/audit.json"
|
||||
],
|
||||
"use_when": [
|
||||
"需要快速查看最近写入的审计记录(默认最近 20 条)"
|
||||
]
|
||||
},
|
||||
"audit verify": {
|
||||
"agent_summary": "校验本地审计日志文件的哈希链完整性",
|
||||
"agent_summary_source": "dws-agent-selection/audit",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"只浏览或导出日志内容时用 audit tail / audit export"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "校验本地审计日志文件的哈希链完整性",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "校验本地审计日志文件的哈希链完整性",
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"只浏览或导出日志内容时用 audit tail / audit export"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"只浏览或导出日志内容时用 audit tail / audit export"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "local",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode local forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Local audit hash-chain verify from #555; no remote RPC."
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/audit.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "纳入 #555 公开 audit leaf 的 Agent 选型文案;本地只读校验,示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "local",
|
||||
"interface_reason": "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"cobra-help:dws audit verify --help",
|
||||
"internal/app/audit_command.go",
|
||||
"internal/cli/schema_command_registry.json#audit.verify",
|
||||
"internal/cli/schema_hints/metadata/audit.json",
|
||||
"internal/cli/schema_hints/selection/audit.json"
|
||||
],
|
||||
"use_when": [
|
||||
"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"product_id": "devdoc",
|
||||
"tools": {
|
||||
"devdoc article search": {
|
||||
"agent_summary": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
|
||||
"agent_summary_source": "dws-agent-selection/devdoc",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
|
||||
"要执行开放平台应用配置变更时用 dev"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws devdoc article search --query \"OAuth2 接入\" --format json",
|
||||
"dws devdoc article search --query \"errcode 40078\" --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "搜索钉钉开放平台开发文档,返回资料与链接(不生成分析答案)",
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword."
|
||||
},
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
|
||||
"要执行开放平台应用配置变更时用 dev"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"搜索用户业务文档用 drive/wiki/doc,不要用 devdoc",
|
||||
"要执行开放平台应用配置变更时用 dev"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword."
|
||||
},
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws devdoc article search --query \"OAuth2 接入\" --format json",
|
||||
"dws devdoc article search --query \"errcode 40078\" --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws devdoc article search --query \"OAuth2 接入\" --format json",
|
||||
"dws devdoc article search --query \"errcode 40078\" --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword."
|
||||
},
|
||||
{
|
||||
"value": "mcp",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": "devdoc.search_open_platform_docs",
|
||||
"source": "internal/cli/schema_hints/imported/wukong.json",
|
||||
"precedence": "imported",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "devdoc.search_open_platform_docs",
|
||||
"source": "internal/cli/schema_hints/imported/wukong.json",
|
||||
"precedence": "imported",
|
||||
"selected": true
|
||||
},
|
||||
{
|
||||
"value": "devdoc.search_open_platform_docs",
|
||||
"source": "internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag.interface_ref",
|
||||
"precedence": "mcp_fallback",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "Search text may be supplied as positional keyword or public --query; hidden --keyword remains a runtime compatibility alias and is intentionally not published as a parameter. The versioned binding owns query -\u003e keyword."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/devdoc.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "mcp",
|
||||
"interface_ref": {
|
||||
"product_id": "devdoc",
|
||||
"rpc_name": "search_open_platform_docs"
|
||||
},
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=devdoc.search_open_platform_docs_rag",
|
||||
"Skill:skills/mono/references/products/devdoc.md",
|
||||
"cobra-help:dws devdoc article search",
|
||||
"dws-wukong-envelope@4574f7022c32:dws-wukong-discovery.devdoc.prod.json#devdoc.search_open_platform_docs",
|
||||
"internal/cli/schema_command_registry.json#devdoc.search_open_platform_docs_rag",
|
||||
"internal/cli/schema_hints/imported/wukong.json",
|
||||
"internal/cli/schema_hints/metadata/devdoc.json",
|
||||
"internal/cli/schema_hints/selection/devdoc.json",
|
||||
"internal/cli/schema_mcp_metadata.json#tools.devdoc.search_open_platform_docs_rag",
|
||||
"live-dws-schema:devdoc.search_open_platform_docs_rag",
|
||||
"skills/mono/references/products/devdoc.md",
|
||||
"skills/mono/references/products/simple.md",
|
||||
"structured-hint:internal/cli/schema_hints/imported/wukong.json#devdoc.search_open_platform_docs_rag",
|
||||
"structured-hint:internal/cli/schema_hints/selection-review.json#devdoc.search_open_platform_docs_rag"
|
||||
],
|
||||
"use_when": [
|
||||
"查 OpenAPI、字段、错误码、OAuth2、接入指南等开放平台开发问题"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user