diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cab641ce..4c024e92 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,6 +5,13 @@ ## Verification +For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks +may be marked `N/A`, but the targeted CHANGELOG check is required. For every +other pull request, mark the targeted check `N/A` and complete the applicable +full-suite checks. + +- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`): + `./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD` - [ ] `make build` - [ ] `make lint` - [ ] `make test` diff --git a/.github/workflows/ai-behavior-check.yml b/.github/workflows/ai-behavior-check.yml index 7aea1df5..3fd9c131 100644 --- a/.github/workflows/ai-behavior-check.yml +++ b/.github/workflows/ai-behavior-check.yml @@ -1,8 +1,11 @@ -name: AI Behavior Check +name: Code Admission — AI Behavior on: pull_request_target: types: [opened, synchronize, reopened, labeled, unlabeled] + push: + branches: + - main permissions: contents: read @@ -11,7 +14,7 @@ permissions: jobs: ai-behavior-check: - name: AI Behavior Policy Evaluator + name: AI Behavior runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -21,75 +24,108 @@ jobs: uses: actions/github-script@v7 with: script: | - const sha = context.payload.pull_request.head.sha; + const pullRequest = context.payload.pull_request; + const sha = context.eventName === 'push' ? context.sha : pullRequest.head.sha; const setStatus = (state, description) => github.rest.repos.createCommitStatus({ owner: context.repo.owner, repo: context.repo.repo, sha, state, - context: 'AI Behavior Check', + context: 'AI Behavior', description, }); await setStatus('pending', 'Evaluating AI-generated PR boundaries'); - const labels = context.payload.pull_request.labels.map(({ name }) => name); - if (!labels.includes('ai-generated')) { - await setStatus('success', 'Not labeled ai-generated'); - core.notice('Not an ai-generated PR; no AI-only policy applied.'); + if (context.eventName === 'push') { + await setStatus('success', 'Not applicable to the protected main push'); + core.notice('AI Behavior is a PR policy; the main push context is sealed.'); return; } - const files = await github.paginate(github.rest.pulls.listFiles, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 100, - }); + try { + const expectedHead = pullRequest.head.sha; + const expectedBase = pullRequest.base.sha; + const currentPull = async (phase) => { + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) { + throw new Error( + `Pull request revision changed during ${phase}: ` + + `expected base/head ${expectedBase}/${expectedHead}, ` + + `got ${pull.base.sha}/${pull.head.sha}` + ); + } + return pull; + }; + + const before = await currentPull('pre-policy check'); + const labels = before.labels.map(({ name }) => name); + if (!labels.includes('ai-generated')) { + await setStatus('success', 'Not labeled ai-generated'); + core.notice('Not an ai-generated PR; no AI-only policy applied.'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + await currentPull('post-policy check'); + + const maxChangedFiles = 30; + if (files.length > maxChangedFiles) { + await setStatus( + 'failure', + `Changes ${files.length} files; limit is ${maxChangedFiles}` + ); + core.setFailed( + `AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.` + ); + return; + } + + const isProtectedPath = (filename) => + typeof filename === 'string' && + ( + filename.startsWith('.github/workflows/') || + filename.startsWith('scripts/ci/') || + filename.startsWith('scripts/policy/') || + filename.startsWith('scripts/release/') || + filename === 'test/fixtures/cli-interface-baseline.txt' || + filename === '.goreleaser.yaml' || + filename === 'Makefile' + ); + const protectedPaths = [...new Set( + files + .flatMap(({ filename, previous_filename }) => [filename, previous_filename]) + .filter(isProtectedPath) + )]; + + if (protectedPaths.length > 0) { + await setStatus('failure', 'Modifies protected release/CI infrastructure'); + core.setFailed( + 'AI-generated PR modifies protected release/CI infrastructure:\n' + + protectedPaths.map((filename) => ` - ${filename}`).join('\n') + + '\nSplit these changes into a human-owned PR with explicit review.' + ); + return; + } - const maxChangedFiles = 30; - if (files.length > maxChangedFiles) { await setStatus( - 'failure', - `Changes ${files.length} files; limit is ${maxChangedFiles}` + 'success', + `Passed with ${files.length} changed files (limit ${maxChangedFiles})` ); - core.setFailed( - `AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.` + core.notice( + `AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).` ); - return; + } catch (error) { + await setStatus('error', 'Could not evaluate the exact pull request revision'); + throw error; } - - const isProtectedPath = (filename) => - typeof filename === 'string' && - ( - filename.startsWith('.github/workflows/') || - filename.startsWith('scripts/policy/') || - filename.startsWith('scripts/release/') || - filename === 'test/fixtures/cli-interface-baseline.txt' || - filename === '.goreleaser.yaml' || - filename === 'Makefile' - ); - const protectedPaths = [...new Set( - files - .flatMap(({ filename, previous_filename }) => [filename, previous_filename]) - .filter(isProtectedPath) - )]; - - if (protectedPaths.length > 0) { - await setStatus('failure', 'Modifies protected release/CI infrastructure'); - core.setFailed( - 'AI-generated PR modifies protected release/CI infrastructure:\n' + - protectedPaths.map((filename) => ` - ${filename}`).join('\n') + - '\nSplit these changes into a human-owned PR with explicit review.' - ); - return; - } - - await setStatus( - 'success', - `Passed with ${files.length} changed files (limit ${maxChangedFiles})` - ); - core.notice( - `AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).` - ); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a81439..633df24a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,48 +18,138 @@ jobs: name: Lint runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + pull-requests: read + outputs: + changelog_only: ${{ steps.classify.outputs.changelog_only }} + changelog_changed: ${{ steps.classify.outputs.changelog_changed }} + platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }} steps: + - name: Classify pull request scope + id: classify + uses: actions/github-script@v7 + with: + script: | + let changelogOnly = false; + let changelogChanged = false; + let platformSensitive = context.eventName === 'push'; + let files = []; + + if (context.eventName === 'pull_request') { + const expectedHead = context.payload.pull_request.head.sha; + const expectedBase = context.payload.pull_request.base.sha; + const assertCurrentRevision = async (phase) => { + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) { + throw new Error( + `Pull request revision changed during ${phase}: ` + + `expected base/head ${expectedBase}/${expectedHead}, ` + + `got ${pull.base.sha}/${pull.head.sha}` + ); + } + return pull; + }; + + const before = await assertCurrentRevision('pre-classification'); + files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + const after = await assertCurrentRevision('post-classification'); + if ( + before.changed_files !== files.length || + after.changed_files !== files.length + ) { + throw new Error( + `Pull request file list is incomplete: API reports ` + + `${after.changed_files} changed files, pagination returned ${files.length}` + ); + } + + changelogOnly = + files.length === 1 && + files[0].filename === 'CHANGELOG.md' && + files[0].status === 'modified' && + !files[0].previous_filename; + changelogChanged = files.some( + ({ filename, previous_filename }) => + filename === 'CHANGELOG.md' || + previous_filename === 'CHANGELOG.md' + ); + + const isPlatformSensitive = (filename) => + typeof filename === 'string' && + ( + filename.startsWith('internal/auth/') || + filename.startsWith('internal/keychain/') || + /_(darwin|windows|linux|unix)\.go$/.test(filename) || + filename.startsWith('scripts/release/') || + filename.startsWith('scripts/install') || + filename.startsWith('Formula/') || + filename.startsWith('build/npm/') || + filename === '.goreleaser.yaml' || + filename === '.github/workflows/release.yml' + ); + platformSensitive = files.some( + ({ filename, previous_filename }) => + isPlatformSensitive(filename) || + isPlatformSensitive(previous_filename) + ); + } + + core.setOutput('changelog_only', String(changelogOnly)); + core.setOutput('changelog_changed', String(changelogChanged)); + core.setOutput('platform_sensitive', String(platformSensitive)); + await core.summary + .addHeading('Code Admission scope') + .addRaw(`- Event: \`${context.eventName}\`\n`) + .addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`) + .addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`) + .addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`) + .addRaw(`- Changed files: \`${files.length}\`\n`) + .write(); + + - name: Record CHANGELOG-only fast path + if: steps.classify.outputs.changelog_only == 'true' + run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY" + - name: Check out repository + if: steps.classify.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: steps.classify.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Verify Test Package Plan + if: steps.classify.outputs.changelog_only != 'true' + run: make test-plan + - name: Format Check - run: | - unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)" - test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1) + if: steps.classify.outputs.changelog_only != 'true' + run: make format-check - name: Go Vet + if: steps.classify.outputs.changelog_only != 'true' run: go vet ./... - # golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25 - # - name: golangci-lint - # uses: golangci/golangci-lint-action@v6 - # with: - # version: v1.64.8 - # args: ./... - - actionlint: - name: Actionlint - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Check GitHub Actions workflows + if: steps.classify.outputs.changelog_only != 'true' run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 test-race: name: "Test (race: ${{ matrix.shard }})" + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 strategy: @@ -79,45 +169,29 @@ jobs: with: go-version-file: go.mod - - name: Install archive tooling - run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Build if: ${{ matrix.shard == 'remaining' }} run: make build - name: Test shard with Race Detection + shell: bash env: + DWS_PACKAGE_VERSION: 0.0.0-test TEST_SHARD: ${{ matrix.shard }} run: | - case "$TEST_SHARD" in - app) - packages=(./internal/app/...) - ;; - generators) - packages=(./internal/generator/...) - ;; - helpers) - packages=(./internal/helpers/...) - ;; - remaining) - mapfile -t packages < <( - go list ./cmd/... ./internal/... | - grep -Ev '/internal/(app|generator|helpers)(/|$)' - ) - ;; - *) - printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2 - exit 1 - ;; - esac + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")" + test -n "$package_output" + mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 go test -v -race -count=1 -timeout=10m "${packages[@]}" test-release-scripts: name: Test (release scripts) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@v4 @@ -131,27 +205,96 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zip unzip - name: Test release scripts - run: go test -v -count=1 -timeout=5m ./test/scripts + shell: bash + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list release-scripts)" + test -n "$package_output" + mapfile -t packages <<< "$package_output" + test "${#packages[@]}" -gt 0 + go test -v -count=1 -timeout=10m "${packages[@]}" + + test-cross-platform: + name: Test (cross-platform compile) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Compile supported operating systems + shell: bash + run: | + set -eu + for target in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + goos="${target%/*}" + goarch="${target#*/}" + output="$RUNNER_TEMP/dws-${goos}-${goarch}" + if [ "$goos" = windows ]; then + output="${output}.exe" + fi + printf 'compile %s/%s\n' "$goos" "$goarch" + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ + go build -o "$output" ./cmd + done test: name: Test needs: + - lint - test-race - test-release-scripts - if: ${{ always() }} + - test-cross-platform + - test-darwin + - test-windows + if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} steps: - name: Verify test shards env: + CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} + PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }} RACE_RESULT: ${{ needs.test-race.result }} RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }} + CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }} + DARWIN_RESULT: ${{ needs.test-darwin.result }} + WINDOWS_RESULT: ${{ needs.test-windows.result }} run: | failed=0 + if [ "$CHANGELOG_ONLY" = true ]; then + for shard in \ + "race shards:$RACE_RESULT" \ + "release scripts:$RELEASE_SCRIPTS_RESULT" \ + "cross-platform compile:$CROSS_PLATFORM_RESULT" \ + "macOS native:$DARWIN_RESULT" \ + "Windows native:$WINDOWS_RESULT" + do + name="${shard%%:*}" + result="${shard#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != skipped ]; then + failed=1 + fi + done + test "$failed" -eq 0 + exit + fi + for shard in \ "race shards:$RACE_RESULT" \ - "release scripts:$RELEASE_SCRIPTS_RESULT" + "release scripts:$RELEASE_SCRIPTS_RESULT" \ + "cross-platform compile:$CROSS_PLATFORM_RESULT" do name="${shard%%:*}" result="${shard#*:}" @@ -160,10 +303,28 @@ jobs: failed=1 fi done + + native_expected=skipped + if [ "$PLATFORM_SENSITIVE" = true ]; then + native_expected=success + fi + for native in \ + "macOS native:$DARWIN_RESULT" \ + "Windows native:$WINDOWS_RESULT" + do + name="${native%%:*}" + result="${native#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != "$native_expected" ]; then + failed=1 + fi + done test "$failed" -eq 0 test-darwin: name: Test (macOS auth/keychain) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: macos-latest timeout-minutes: 15 steps: @@ -180,6 +341,8 @@ jobs: test-windows: name: Test (Windows) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: windows-latest timeout-minutes: 15 steps: @@ -202,6 +365,8 @@ jobs: coverage-darwin: name: Coverage (macOS) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: macos-latest timeout-minutes: 20 steps: @@ -244,6 +409,8 @@ jobs: coverage-windows: name: Coverage (Windows) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: windows-latest timeout-minutes: 20 steps: @@ -288,6 +455,8 @@ jobs: coverage-current: name: Coverage (current) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -321,6 +490,8 @@ jobs: coverage-supporting: name: Coverage (supporting) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -358,6 +529,8 @@ jobs: coverage-baseline: name: Coverage (baseline) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -421,20 +594,35 @@ jobs: coverage: name: Coverage needs: + - lint - coverage-current - coverage-supporting - coverage-baseline - if: ${{ always() }} + - coverage-darwin + - coverage-windows + if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Verify coverage profile jobs env: + CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} + PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }} CURRENT_RESULT: ${{ needs.coverage-current.result }} SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} BASELINE_RESULT: ${{ needs.coverage-baseline.result }} + DARWIN_RESULT: ${{ needs.coverage-darwin.result }} + WINDOWS_RESULT: ${{ needs.coverage-windows.result }} run: | failed=0 + expected=success + native_expected=skipped + if [ "$CHANGELOG_ONLY" = true ]; then + expected=skipped + elif [ "$PLATFORM_SENSITIVE" = true ]; then + native_expected=success + fi + for profile in \ "current:$CURRENT_RESULT" \ "supporting:$SUPPORTING_RESULT" \ @@ -443,24 +631,42 @@ jobs: name="${profile%%:*}" result="${profile#*:}" printf '%s: %s\n' "$name" "$result" - if [ "$result" != "success" ]; then + if [ "$result" != "$expected" ]; then + failed=1 + fi + done + + for native in \ + "macOS native:$DARWIN_RESULT" \ + "Windows native:$WINDOWS_RESULT" + do + name="${native%%:*}" + result="${native#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$CHANGELOG_ONLY" = true ]; then + native_expected=skipped + fi + if [ "$result" != "$native_expected" ]; then failed=1 fi done test "$failed" -eq 0 - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Resolve authoritative coverage base + if: needs.lint.outputs.changelog_only != 'true' env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -478,33 +684,40 @@ jobs: echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" - name: Download current coverage profile + if: needs.lint.outputs.changelog_only != 'true' uses: actions/download-artifact@v4 with: name: coverage-current-profile path: . - name: Download supporting coverage profiles + if: needs.lint.outputs.changelog_only != 'true' uses: actions/download-artifact@v4 with: name: coverage-supporting-profiles path: . - name: Download baseline coverage profile + if: needs.lint.outputs.changelog_only != 'true' uses: actions/download-artifact@v4 with: name: coverage-baseline-profile path: . - name: Enforce coverage gate + if: needs.lint.outputs.changelog_only != 'true' env: - COVERAGE_TARGET: "80" + COVERAGE_TARGET: "100" COVERAGE_ENFORCE_OVERALL: "false" - run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF" + COVERAGE_OVERALL_TOLERANCE: "0" + run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF" - name: Generate coverage report + if: needs.lint.outputs.changelog_only != 'true' run: go tool cover -html=coverage.txt -o coverage.html - name: Upload coverage artifact + if: needs.lint.outputs.changelog_only != 'true' uses: actions/upload-artifact@v4 with: name: coverage-report @@ -516,44 +729,114 @@ jobs: coverage.html policy: - name: Policy Check + name: Policy + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Verify pull request merge revision + if: github.event_name == 'pull_request' + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -eu + test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || { + echo "checked-out merge first parent does not match event base" >&2 + exit 1 + } + test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || { + echo "checked-out merge second parent does not match event head" >&2 + exit 1 + } + + - name: Validate changed CHANGELOG content + if: github.event_name == 'pull_request' + env: + CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }} + CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -eu + merge_changelog_changed=false + if git diff --no-ext-diff --find-renames --name-status \ + "$PR_BASE_SHA" HEAD | + awk -F '\t' ' + { + for (field = 2; field <= NF; field++) { + if ($field == "CHANGELOG.md") found = 1 + } + } + END { exit !found } + ' + then + merge_changelog_changed=true + fi + test "$merge_changelog_changed" = "$CLASSIFIED_CHANGELOG_CHANGED" || { + echo "Files API and synthetic merge tree disagree on CHANGELOG scope" >&2 + exit 1 + } + if [ "$merge_changelog_changed" != true ]; then + exit 0 + fi + + mode=--content-only + if [ "$CHANGELOG_ONLY" = true ]; then + mode=--fast-path + fi + ./scripts/policy/check-changelog-pr.sh \ + "$mode" "$PR_BASE_SHA" HEAD + + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: | + echo "Only the base-equivalent CHANGELOG validator ran; full Policy resumes on main." \ + >> "$GITHUB_STEP_SUMMARY" + - name: Build + if: needs.lint.outputs.changelog_only != 'true' run: make build - name: Policy + if: needs.lint.outputs.changelog_only != 'true' run: make policy interface-integrity: name: Interface Integrity + needs: lint runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Build + if: needs.lint.outputs.changelog_only != 'true' run: make build - name: Resolve authoritative compatibility merge-base + if: needs.lint.outputs.changelog_only != 'true' env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -578,6 +861,7 @@ jobs: echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV" - name: Check historical commands and help compatibility + if: needs.lint.outputs.changelog_only != 'true' run: | make authoritative-interface-integrity \ BASE_REF="$COMPATIBILITY_BASE_REF" @@ -587,143 +871,89 @@ jobs: fi - name: Check complete Schema compatibility + if: needs.lint.outputs.changelog_only != 'true' run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF" - name: Check skill command references + if: needs.lint.outputs.changelog_only != 'true' run: make skill-command-integrity + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" + cli-smoke: name: CLI Smoke + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Build + if: needs.lint.outputs.changelog_only != 'true' run: make build - name: Check public top-level commands + if: needs.lint.outputs.changelog_only != 'true' run: make cli-smoke + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" + mock-mcp-smoke: - name: Mock MCP Smoke + name: Mock MCP + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Check HTTP and stdio MCP transport + if: needs.lint.outputs.changelog_only != 'true' run: make mock-mcp-smoke + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" + edition-tests: - name: Edition Contract Tests + name: Edition + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Run edition contract tests + if: needs.lint.outputs.changelog_only != 'true' run: go test -v -count=1 ./pkg/editiontest/... - ci-gate: - name: CI Gate - needs: - - lint - - actionlint - - test - - test-darwin - - test-windows - - coverage - - coverage-darwin - - coverage-windows - - policy - - interface-integrity - - cli-smoke - - mock-mcp-smoke - - edition-tests - if: ${{ always() }} - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: {} - steps: - - name: Verify required checks - env: - LINT_RESULT: ${{ needs.lint.result }} - ACTIONLINT_RESULT: ${{ needs.actionlint.result }} - TEST_RESULT: ${{ needs.test.result }} - TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }} - TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }} - COVERAGE_RESULT: ${{ needs.coverage.result }} - COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }} - COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }} - POLICY_RESULT: ${{ needs.policy.result }} - INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }} - CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }} - MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }} - EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }} - run: | - failed=0 - for check in \ - "Lint:$LINT_RESULT" \ - "Actionlint:$ACTIONLINT_RESULT" \ - "Test:$TEST_RESULT" \ - "Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \ - "Test (Windows):$TEST_WINDOWS_RESULT" \ - "Coverage:$COVERAGE_RESULT" \ - "Coverage (macOS):$COVERAGE_DARWIN_RESULT" \ - "Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \ - "Policy Check:$POLICY_RESULT" \ - "Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \ - "CLI Smoke:$CLI_SMOKE_RESULT" \ - "Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \ - "Edition Contract Tests:$EDITION_TESTS_RESULT" - do - name="${check%%:*}" - result="${check#*:}" - printf '%s: %s\n' "$name" "$result" - if [ "$result" != "success" ]; then - failed=1 - fi - done - test "$failed" -eq 0 - - notify-downstream: - name: Notify Wukong Overlay - needs: [ci-gate] - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - permissions: {} - steps: - - name: Trigger downstream CI - run: | - # Trigger internal GitLab CI pipeline via webhook. - # WUKONG_TRIGGER_TOKEN is a repository secret. - if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then - curl --fail --silent --show-error \ - -X POST \ - -F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \ - -F "ref=main" \ - -F "variables[UPSTREAM_SHA]=${{ github.sha }}" \ - "${{ secrets.WUKONG_TRIGGER_URL }}" - echo "Downstream CI triggered." - else - echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification." - fi + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/multi-profile-e2e.yml b/.github/workflows/multi-profile-e2e.yml index 61a1d9ae..6695d4a1 100644 --- a/.github/workflows/multi-profile-e2e.yml +++ b/.github/workflows/multi-profile-e2e.yml @@ -1,8 +1,9 @@ -name: Multi Profile E2E +name: Main Integration — 主干集成 on: - pull_request: push: + branches: + - main workflow_dispatch: permissions: @@ -14,7 +15,7 @@ concurrency: jobs: multi-profile-e2e: - name: Multi Profile E2E + name: Multi-profile E2E runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -36,7 +37,7 @@ jobs: mkdir -p .tmp-bin bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG" { - echo "### Multi Profile E2E" + echo "### Multi-profile E2E" echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`" echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration" echo "- Result: passed" diff --git a/.github/workflows/notify-wukong.yml b/.github/workflows/notify-wukong.yml new file mode 100644 index 00000000..d9897c73 --- /dev/null +++ b/.github/workflows/notify-wukong.yml @@ -0,0 +1,38 @@ +name: Main Integration — Wukong Overlay + +on: + workflow_run: + workflows: + - CI + types: + - completed + +permissions: {} + +jobs: + notify-downstream: + name: Notify Wukong Overlay + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Trigger downstream CI + env: + UPSTREAM_SHA: ${{ github.event.workflow_run.head_sha }} + WUKONG_TRIGGER_TOKEN: ${{ secrets.WUKONG_TRIGGER_TOKEN }} + WUKONG_TRIGGER_URL: ${{ secrets.WUKONG_TRIGGER_URL }} + run: | + if [ -n "$WUKONG_TRIGGER_TOKEN" ]; then + curl --fail --silent --show-error \ + -X POST \ + -F "token=$WUKONG_TRIGGER_TOKEN" \ + -F "ref=main" \ + -F "variables[UPSTREAM_SHA]=$UPSTREAM_SHA" \ + "$WUKONG_TRIGGER_URL" + echo "Downstream CI triggered." + else + echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification." + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08adb13c..13cc8fbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ name: Release -run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.workflow }} +run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_operation != 'none' && format('Release {0} {1} ({2})', inputs.release_operation, inputs.release_channel, inputs.release_bump) || github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }} on: push: @@ -7,10 +7,48 @@ on: - "v*" workflow_dispatch: inputs: + release_operation: + description: "Plan the next version or publish it from the current default-branch commit" + required: true + default: none + type: choice + options: + - none + - plan + - publish + release_channel: + description: "Release channel (used only when release_operation is plan/publish)" + required: true + default: beta + type: choice + options: + - beta + - stable + release_bump: + description: "New beta core bump; ignored while continuing an open beta line" + required: true + default: patch + type: choice + options: + - patch + - minor + - major + release_confirmation: + description: "For publish, type PUBLISH beta or PUBLISH stable" + required: false + type: string repair_npm_version: description: "Recover npm for a public immutable release without moving latest/beta" required: false type: string + repair_gitee_version: + description: "Mirror an existing public immutable release's assets to Gitee" + required: false + type: string + repair_oss_version: + description: "Mirror an existing public immutable release's assets to OSS" + required: false + type: string governance_preflight_commit: description: "Verify release governance for this exact default-branch commit without publishing" required: false @@ -51,9 +89,9 @@ on: permissions: contents: read -# Queue governance checks, tag releases, protected recoveries, and npm repairs -# behind one publication lock. The local entrypoint also refuses to tag while -# a Release run is active. +# Queue governance checks, tag releases, protected recoveries, and channel +# repairs behind one publication lock. The local entrypoint also refuses to tag +# while a Release run is active. concurrency: group: dws-release-publication queue: max @@ -70,6 +108,8 @@ jobs: id: mode env: REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} + REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }} + REPAIR_OSS_VERSION: ${{ inputs.repair_oss_version }} GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }} GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }} RECOVER_VERSION: ${{ inputs.recover_release_version }} @@ -79,24 +119,65 @@ jobs: RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }} RECOVER_NONCE: ${{ inputs.recover_release_nonce }} RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }} + RELEASE_OPERATION: ${{ inputs.release_operation }} + RELEASE_CHANNEL: ${{ inputs.release_channel }} + RELEASE_BUMP: ${{ inputs.release_bump }} + RELEASE_CONFIRMATION: ${{ inputs.release_confirmation }} run: | set -eu - repair=0 + release_flow=0 + npm_repair=0 + gitee_repair=0 + oss_repair=0 governance=0 recovery=0 - test -z "$REPAIR_NPM_VERSION" || repair=1 + if test -n "$RELEASE_OPERATION" && test "$RELEASE_OPERATION" != none; then + release_flow=1 + fi + test -z "$REPAIR_NPM_VERSION" || npm_repair=1 + test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1 + test -z "$REPAIR_OSS_VERSION" || oss_repair=1 if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \ test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \ test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \ test -n "$RECOVER_NONCE" || \ test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi - test $((repair + governance + recovery)) -eq 1 || { + test $((release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || { echo "workflow_dispatch must select exactly one release mode" >&2 exit 1 } - if test "$repair" -eq 1; then + if test "$release_flow" -eq 1; then + case "$RELEASE_CHANNEL" in beta|stable) ;; *) + echo "release_channel must be beta or stable" >&2 + exit 1 + esac + case "$RELEASE_BUMP" in patch|minor|major) ;; *) + echo "release_bump must be patch, minor, or major" >&2 + exit 1 + esac + if test "$RELEASE_OPERATION" = plan; then + test -z "$RELEASE_CONFIRMATION" || { + echo "release_confirmation must be empty for a plan" >&2 + exit 1 + } + echo "mode=plan_release" >> "$GITHUB_OUTPUT" + elif test "$RELEASE_OPERATION" = publish; then + test "$RELEASE_CONFIRMATION" = "PUBLISH $RELEASE_CHANNEL" || { + echo "release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL" >&2 + exit 1 + } + echo "mode=create_release" >> "$GITHUB_OUTPUT" + else + echo "release_operation must be none, plan, or publish" >&2 + exit 1 + fi + elif test "$npm_repair" -eq 1; then echo "mode=repair_npm" >> "$GITHUB_OUTPUT" + elif test "$gitee_repair" -eq 1; then + echo "mode=repair_gitee" >> "$GITHUB_OUTPUT" + elif test "$oss_repair" -eq 1; then + echo "mode=repair_oss" >> "$GITHUB_OUTPUT" elif test "$governance" -eq 1; then if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then echo "governance preflight requires both commit and nonce" >&2 @@ -179,7 +260,7 @@ jobs: governance-preflight: name: Release governance preflight needs: dispatch-contract - if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' }} + if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' || needs.dispatch-contract.outputs.mode == 'create_release' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -190,7 +271,8 @@ jobs: env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli - PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }} + PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} PREFLIGHT_NONCE: ${{ inputs.governance_preflight_nonce }} REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} run: | @@ -207,10 +289,24 @@ jobs: echo "governance preflight cannot be combined with npm repair" >&2 exit 1 } - if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then - echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2 - exit 1 - fi + case "$DISPATCH_MODE" in + governance_preflight) + if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then + echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2 + exit 1 + fi + ;; + create_release) + test -z "$PREFLIGHT_NONCE" || { + echo "cloud release cannot be combined with a governance nonce" >&2 + exit 1 + } + ;; + *) + echo "unsupported governance preflight mode: $DISPATCH_MODE" >&2 + exit 1 + ;; + esac case "$PREFLIGHT_COMMIT" in *[!0-9a-f]*) echo "governance_preflight_commit must be a lowercase commit SHA" >&2 @@ -226,32 +322,62 @@ jobs: exit 1 } - - name: Require successful CI Gate on the preflight commit + - name: Require successful Code Admission contexts on the preflight commit uses: actions/github-script@v7 env: - PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} with: script: | const { owner, repo } = context.repo; - const { data } = await github.rest.checks.listForRef({ + const sha = process.env.PREFLIGHT_COMMIT; + const requiredContexts = [ + "Lint", + "Test", + "Coverage", + "Policy", + "Edition", + "Interface Integrity", + "AI Behavior", + "CLI Smoke", + "Mock MCP", + ]; + const runs = await github.paginate(github.rest.checks.listForRef, { owner, repo, - ref: process.env.PREFLIGHT_COMMIT, - check_name: "CI Gate", + ref: sha, filter: "latest", per_page: 100, }); - const passed = data.check_runs.some( - (run) => run.name === "CI Gate" && run.conclusion === "success", - ); - if (!passed) { - core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`); + const latestByName = new Map(); + for (const run of runs) { + if (run.head_sha !== sha || !requiredContexts.includes(run.name)) { + continue; + } + const current = latestByName.get(run.name); + if (!current || run.id > current.id) { + latestByName.set(run.name, run); + } + } + const missing = requiredContexts.filter((name) => !latestByName.has(name)); + const nonSuccess = requiredContexts.flatMap((name) => { + const run = latestByName.get(name); + if (!run || run.conclusion === "success") { + return []; + } + return [`${name}=${run.conclusion || run.status || "unknown"}`]; + }); + if (missing.length > 0 || nonSuccess.length > 0) { + core.setFailed( + `Code Admission contexts are not all successful for ${sha}; ` + + `missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` + + `non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`, + ); } - name: Check out trusted preflight tooling uses: actions/checkout@v4 with: - ref: ${{ inputs.governance_preflight_commit }} + ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} persist-credentials: false fetch-depth: 0 @@ -300,8 +426,8 @@ jobs: ./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary release-contract: - needs: [dispatch-contract, authorize-recovery] - if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }} + needs: [dispatch-contract, authorize-recovery, governance-preflight, release-plan, seal-release] + if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -312,6 +438,7 @@ jobs: release_version: ${{ steps.target.outputs.release_version }} release_commit: ${{ steps.target.outputs.release_commit }} release_tag_object: ${{ steps.target.outputs.release_tag_object }} + oss_mirror: ${{ steps.target.outputs.oss_mirror }} is_recovery: ${{ steps.target.outputs.is_recovery }} failed_run_id: ${{ steps.target.outputs.failed_run_id }} channel: ${{ steps.contract.outputs.channel }} @@ -329,16 +456,28 @@ jobs: RECOVER_COMMIT: ${{ inputs.recover_release_commit }} RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }} RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }} + DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }} + SEALED_VERSION: ${{ needs.seal-release.outputs.release_version }} + SEALED_COMMIT: ${{ needs.seal-release.outputs.release_commit }} + SEALED_TAG_OBJECT: ${{ needs.seal-release.outputs.release_tag_object }} with: script: | const { owner, repo } = context.repo; - const isRecovery = context.eventName === "workflow_dispatch"; + const dispatchMode = process.env.DISPATCH_MODE; + const isRecovery = + context.eventName === "workflow_dispatch" && dispatchMode === "recover_release"; + const isCreate = + context.eventName === "workflow_dispatch" && dispatchMode === "create_release"; let version = context.ref.replace("refs/tags/", ""); let commit = context.sha; let expectedTagObject = ""; let failedRunId = ""; let failedRunAttempt = ""; - if (isRecovery) { + if (context.eventName === "workflow_dispatch" && !isRecovery && !isCreate) { + core.setFailed(`unsupported publication dispatch mode: ${dispatchMode}`); + return; + } + if (isRecovery || isCreate) { if ( context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" || context.ref !== `refs/heads/${context.payload.repository.default_branch}` @@ -346,24 +485,28 @@ jobs: core.setFailed("release recovery must be dispatched from the official default branch"); return; } - version = process.env.RECOVER_VERSION; - commit = process.env.RECOVER_COMMIT; - expectedTagObject = process.env.RECOVER_TAG_OBJECT; - failedRunId = process.env.RECOVER_FAILED_RUN_ID; - failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT; + version = isCreate ? process.env.SEALED_VERSION : process.env.RECOVER_VERSION; + commit = isCreate ? process.env.SEALED_COMMIT : process.env.RECOVER_COMMIT; + expectedTagObject = isCreate + ? process.env.SEALED_TAG_OBJECT + : process.env.RECOVER_TAG_OBJECT; + if (isRecovery) { + failedRunId = process.env.RECOVER_FAILED_RUN_ID; + failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT; + } if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version)) { - core.setFailed(`invalid recovery release version: ${version}`); + core.setFailed(`invalid dispatched release version: ${version}`); return; } if (!/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(expectedTagObject)) { - core.setFailed("recovery tag object and commit must be full lowercase SHA-1 values"); + core.setFailed("dispatched tag object and commit must be full lowercase SHA-1 values"); return; } - if (!/^[1-9]\d*$/.test(failedRunId)) { + if (isRecovery && !/^[1-9]\d*$/.test(failedRunId)) { core.setFailed("recover_failed_run_id must be a positive workflow run ID"); return; } - if (!/^[1-9]\d*$/.test(failedRunAttempt)) { + if (isRecovery && !/^[1-9]\d*$/.test(failedRunAttempt)) { core.setFailed("recover_failed_run_attempt must be a positive run attempt"); return; } @@ -384,12 +527,74 @@ jobs: core.setFailed(`${version} resolves to ${tag.data.object.sha}, expected ${commit}`); return; } - if (isRecovery && tagObject !== expectedTagObject) { + if ((isRecovery || isCreate) && tagObject !== expectedTagObject) { core.setFailed(`${version} tag object is ${tagObject}, expected ${expectedTagObject}`); return; } + const tagFields = new Map(); + for (const line of tag.data.message.split(/\r?\n/)) { + const separator = line.indexOf(": "); + if (separator < 1) continue; + const key = line.slice(0, separator); + if (tagFields.has(key)) { + core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`); + return; + } + tagFields.set(key, line.slice(separator + 2)); + } + const ossMirror = tagFields.has("OSS-Mirror") + ? tagFields.get("OSS-Mirror") + : "enabled"; + if (!["enabled", "deferred"].includes(ossMirror)) { + core.setFailed(`${version} contains invalid OSS-Mirror metadata`); + return; + } + const cloudOnlyKeys = [ + "Release-Run", + "Release-Run-Attempt", + "Requested-By", + "Requested-By-ID", + "Sealed-Commit", + "Workflow-Commit", + "Allocation-Fingerprint", + ]; + const cloudKeys = ["Channel", ...cloudOnlyKeys]; + const hasAnyCloudMetadata = cloudOnlyKeys.some((key) => tagFields.has(key)); + const isCloudSeal = cloudKeys.every((key) => tagFields.has(key)); + if (hasAnyCloudMetadata && !isCloudSeal) { + core.setFailed(`${version} contains incomplete cloud seal metadata`); + return; + } + if (isCloudSeal) { + const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable"; + const actorId = String(context.payload.sender?.id || ""); + if ( + tagFields.get("Channel") !== expectedChannel || + tagFields.get("Sealed-Commit") !== commit || + tagFields.get("Workflow-Commit") !== commit || + !/^[0-9a-f]{64}$/.test(tagFields.get("Allocation-Fingerprint")) || + !/^[1-9]\d*$/.test(tagFields.get("Release-Run")) || + !/^[1-9]\d*$/.test(tagFields.get("Release-Run-Attempt")) || + !/^[1-9]\d*$/.test(tagFields.get("Requested-By-ID")) + ) { + core.setFailed(`${version} cloud seal metadata does not match its tag authority`); + return; + } + if (isCreate && ( + tagFields.get("Release-Run") !== String(context.runId) || + tagFields.get("Release-Run-Attempt") !== process.env.GITHUB_RUN_ATTEMPT || + tagFields.get("Requested-By") !== context.actor || + tagFields.get("Requested-By-ID") !== actorId + )) { + core.setFailed(`${version} cloud seal is not bound to this release request`); + return; + } + } else if (isCreate) { + core.setFailed(`${version} is missing cloud seal metadata`); + return; + } - if (isRecovery) { + if (isRecovery || isCreate) { const defaultBranch = context.payload.repository.default_branch; const branch = await github.rest.repos.getBranch({ owner, repo, branch: defaultBranch }); const comparison = await github.rest.repos.compareCommitsWithBasehead({ @@ -401,6 +606,8 @@ jobs: core.setFailed(`${version} commit is not contained in ${defaultBranch}`); return; } + } + if (isRecovery) { const failedRun = await github.request( "GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}", { @@ -411,19 +618,34 @@ jobs: }, ); const run = failedRun.data; + const failedByCloud = isCloudSeal; + const expectedBranch = failedByCloud + ? context.payload.repository.default_branch + : version; + const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"; if ( run.id !== Number(failedRunId) || run.run_attempt !== Number(failedRunAttempt) || run.repository.full_name !== `${owner}/${repo}` || run.path !== ".github/workflows/release.yml" || - run.event !== "push" || + run.event !== expectedEvent || run.status !== "completed" || !["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion) || - run.head_branch !== version || + run.head_branch !== expectedBranch || run.head_sha !== commit ) { core.setFailed( - `run ${failedRunId} attempt ${failedRunAttempt} is not the failed exact-tag Release attempt for ${version}`, + `run ${failedRunId} attempt ${failedRunAttempt} is not the failed sealed Release attempt for ${version}`, + ); + return; + } + if (failedByCloud && ( + tagFields.get("Release-Run") !== failedRunId || + tagFields.get("Release-Run-Attempt") !== failedRunAttempt || + tagFields.get("Requested-By-ID") !== String(run.actor?.id || "") + )) { + core.setFailed( + `run ${failedRunId} attempt ${failedRunAttempt} is not bound by the cloud seal for ${version}`, ); return; } @@ -441,6 +663,7 @@ jobs: core.setOutput("release_version", version); core.setOutput("release_commit", commit); core.setOutput("release_tag_object", tagObject); + core.setOutput("oss_mirror", ossMirror); core.setOutput("is_recovery", isRecovery ? "true" : "false"); core.setOutput("failed_run_id", failedRunId); @@ -464,7 +687,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Validate release contract id: contract @@ -493,26 +717,56 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - - name: Require successful CI Gate on the sealed commit + - name: Require successful Code Admission contexts on the sealed commit uses: actions/github-script@v7 env: RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }} with: script: | const { owner, repo } = context.repo; - const { data } = await github.rest.checks.listForRef({ + const sha = process.env.RELEASE_COMMIT; + const requiredContexts = [ + "Lint", + "Test", + "Coverage", + "Policy", + "Edition", + "Interface Integrity", + "AI Behavior", + "CLI Smoke", + "Mock MCP", + ]; + const runs = await github.paginate(github.rest.checks.listForRef, { owner, repo, - ref: process.env.RELEASE_COMMIT, - check_name: "CI Gate", + ref: sha, filter: "latest", per_page: 100, }); - const passed = data.check_runs.some( - (run) => run.name === "CI Gate" && run.conclusion === "success", - ); - if (!passed) { - core.setFailed(`CI Gate has not succeeded for ${process.env.RELEASE_COMMIT}`); + const latestByName = new Map(); + for (const run of runs) { + if (run.head_sha !== sha || !requiredContexts.includes(run.name)) { + continue; + } + const current = latestByName.get(run.name); + if (!current || run.id > current.id) { + latestByName.set(run.name, run); + } + } + const missing = requiredContexts.filter((name) => !latestByName.has(name)); + const nonSuccess = requiredContexts.flatMap((name) => { + const run = latestByName.get(name); + if (!run || run.conclusion === "success") { + return []; + } + return [`${name}=${run.conclusion || run.status || "unknown"}`]; + }); + if (missing.length > 0 || nonSuccess.length > 0) { + core.setFailed( + `Code Admission contexts are not all successful for ${sha}; ` + + `missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` + + `non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`, + ); } - name: Require delivered previous stable baseline @@ -679,7 +933,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing release source env: @@ -712,7 +967,7 @@ jobs: - name: Install archive tooling run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Multi Profile E2E + - name: Multi-profile E2E run: bash scripts/dev/test-multi-profile-e2e.sh - name: Install rcodesign (sign darwin binaries from Linux) @@ -874,7 +1129,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing publication source env: @@ -929,6 +1185,7 @@ jobs: "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - name: Publish or reuse immutable GitHub Release + id: publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }} @@ -941,9 +1198,36 @@ jobs: prerelease_flag="--prerelease" fi + release_id="" if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then - is_draft="$(gh release view "$RELEASE_VERSION" --json isDraft --jq .isDraft)" - is_prerelease="$(gh release view "$RELEASE_VERSION" --json isPrerelease --jq .isPrerelease)" + release_id="$( + gh release view "$RELEASE_VERSION" \ + --json databaseId \ + --jq .databaseId + )" + printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || { + echo "Existing GitHub Release has an invalid database ID: $release_id" >&2 + exit 1 + } + resolved_tag="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .tag_name + )" + test "$resolved_tag" = "$RELEASE_VERSION" || { + echo "GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2 + exit 1 + } + is_draft="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .draft + )" + is_prerelease="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .prerelease + )" if [ "$is_prerelease" != "$expected_prerelease" ]; then echo "Existing release channel does not match $RELEASE_CHANNEL." >&2 exit 1 @@ -951,14 +1235,18 @@ jobs: if [ "$is_draft" = "false" ]; then is_immutable="$( gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ --jq .immutable )" if [ "$is_immutable" != "true" ]; then echo "Published release is mutable; refusing to reuse its assets." >&2 exit 1 fi - remote_body="$(gh release view "$RELEASE_VERSION" --json body --jq .body)" + remote_body="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .body + )" local_body="$(cat "$RUNNER_TEMP/release-notes.md")" if [ "$remote_body" != "$local_body" ]; then echo "Published release notes differ from the sealed CHANGELOG." >&2 @@ -976,11 +1264,9 @@ jobs: fi published_dir="$RUNNER_TEMP/published-release" mkdir -p "$published_dir" - gh release download "$RELEASE_VERSION" \ - --dir "$published_dir" \ - --pattern 'dws-*' \ - --pattern 'checksums.txt' \ - --clobber + DWS_GITHUB_RELEASE_ID="$release_id" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ + "$RELEASE_VERSION" "$published_dir" DWS_PACKAGE_DIST_DIR="$published_dir" \ ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do @@ -993,12 +1279,10 @@ jobs: DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \ ./scripts/release/stage-npm-package.sh "$RELEASE_VERSION" + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" echo "Published GitHub Release is immutable and verified; reusing its exact assets." exit 0 fi - gh release edit "$RELEASE_VERSION" \ - --title "$RELEASE_VERSION" \ - --notes-file "$RUNNER_TEMP/release-notes.md" else # shellcheck disable=SC2086 gh release create "$RELEASE_VERSION" \ @@ -1007,6 +1291,63 @@ jobs: --title "$RELEASE_VERSION" \ --notes-file "$RUNNER_TEMP/release-notes.md" \ $prerelease_flag + release_id="$( + gh release view "$RELEASE_VERSION" \ + --json databaseId \ + --jq .databaseId + )" + fi + printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || { + echo "Draft GitHub Release has an invalid database ID: $release_id" >&2 + exit 1 + } + resolved_tag="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .tag_name + )" + test "$resolved_tag" = "$RELEASE_VERSION" || { + echo "Draft GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2 + exit 1 + } + gh api --method PATCH \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + -f tag_name="$RELEASE_VERSION" \ + -f target_commitish="$RELEASE_COMMIT" \ + -f name="$RELEASE_VERSION" \ + -f body="$(cat "$RUNNER_TEMP/release-notes.md")" \ + -F draft=true \ + -F prerelease="$expected_prerelease" \ + >/dev/null + draft_state="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq '[.tag_name, .draft, .prerelease] | @tsv' + )" + test "$draft_state" = "$(printf '%s\ttrue\t%s' "$RELEASE_VERSION" "$expected_prerelease")" || { + echo "Draft GitHub Release identity or channel changed before upload." >&2 + exit 1 + } + draft_body="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .body + )" + local_body="$(cat "$RUNNER_TEMP/release-notes.md")" + test "$draft_body" = "$local_body" || { + echo "Draft GitHub Release notes differ from the sealed CHANGELOG." >&2 + exit 1 + } + if [ "$IS_RECOVERY" = "true" ]; then + recovery_marker="" + case "$draft_body" in + *"$recovery_marker"*) ;; + *) + echo "Draft GitHub Release is not bound to this exact recovery run." >&2 + exit 1 + ;; + esac fi gh release upload "$RELEASE_VERSION" \ dist/dws-*.tar.gz \ @@ -1014,15 +1355,24 @@ jobs: dist/dws-skills.zip \ dist/checksums.txt \ --clobber - ./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION" + uploaded_release_id="$( + gh release view "$RELEASE_VERSION" \ + --json databaseId \ + --jq .databaseId + )" + test "$uploaded_release_id" = "$release_id" || { + echo "GitHub Release identity changed during upload: $release_id -> $uploaded_release_id" >&2 + exit 1 + } + DWS_GITHUB_RELEASE_ID="$release_id" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \ + "$RELEASE_VERSION" sealed_upload="$RUNNER_TEMP/sealed-upload" rm -rf "$sealed_upload" mkdir -p "$sealed_upload" - gh release download "$RELEASE_VERSION" \ - --dir "$sealed_upload" \ - --pattern 'dws-*' \ - --pattern 'checksums.txt' \ - --clobber + DWS_GITHUB_RELEASE_ID="$release_id" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ + "$RELEASE_VERSION" "$sealed_upload" DWS_PACKAGE_DIST_DIR="$sealed_upload" \ ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do @@ -1032,23 +1382,76 @@ jobs: exit 1 } done - gh release edit "$RELEASE_VERSION" --draft=false + gh api --method PATCH \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + -f name="$RELEASE_VERSION" \ + -f body="$(cat "$RUNNER_TEMP/release-notes.md")" \ + -F prerelease="$expected_prerelease" \ + -F draft=false \ + >/dev/null + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" - name: Require immutable published GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_ID: ${{ steps.publish.outputs.release_id }} + RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }} run: | set -eu - immutable="$( - gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \ - --jq .immutable - )" - test "$immutable" = true || { - echo "GitHub Release is not immutable; refusing downstream publication." >&2 + expected_prerelease=false + if [ "$RELEASE_CHANNEL" = "prerelease" ]; then expected_prerelease=true; fi + printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || { + echo "Published GitHub Release has an invalid database ID: $RELEASE_ID" >&2 exit 1 } - ./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION" + release_state="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + --jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv' + )" + test "$release_state" = "$(printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease")" || { + echo "GitHub Release ID $RELEASE_ID is not the exact public immutable $RELEASE_VERSION release." >&2 + exit 1 + } + immutable_body="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + --jq .body + )" + local_body="$(cat "$RUNNER_TEMP/release-notes.md")" + test "$immutable_body" = "$local_body" || { + echo "Immutable GitHub Release notes differ from the sealed CHANGELOG." >&2 + exit 1 + } + if [ "$IS_RECOVERY" = "true" ]; then + recovery_marker="" + case "$immutable_body" in + *"$recovery_marker"*) ;; + *) + echo "Immutable GitHub Release is not bound to this exact recovery run." >&2 + exit 1 + ;; + esac + fi + DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \ + "$RELEASE_VERSION" + immutable_dir="$RUNNER_TEMP/immutable-release" + rm -rf "$immutable_dir" + mkdir -p "$immutable_dir" + DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ + "$RELEASE_VERSION" "$immutable_dir" + DWS_PACKAGE_DIST_DIR="$immutable_dir" \ + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do + sealed_asset="dist/$(basename "$immutable_asset")" + cmp -s "$sealed_asset" "$immutable_asset" || { + echo "Immutable GitHub asset differs from this run's sealed bytes: $(basename "$immutable_asset")" >&2 + exit 1 + } + done "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" @@ -1112,7 +1515,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing channel source env: @@ -1290,15 +1694,31 @@ jobs: run: | set -eu . ./scripts/release/release-lib.sh - delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")" - if [ "$delivered" = "$SEMVER" ]; then exit 0; fi - release_version_is_greater "v$delivered" "v$SEMVER" || { - echo "npm $NPM_TAG=$delivered does not deliver or supersede v$SEMVER" >&2 - exit 1 - } - echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered." + attempt=1 + max_attempts=12 + while :; do + delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG" \ + --registry=https://registry.npmjs.org --prefer-online)" + if [ "$delivered" = "$SEMVER" ]; then exit 0; fi + if release_version_is_greater "v$delivered" "v$SEMVER"; then + echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered." + exit 0 + fi + release_version_is_greater "v$SEMVER" "v$delivered" || { + echo "npm $NPM_TAG=$delivered cannot be reconciled with v$SEMVER" >&2 + exit 1 + } + if [ "$attempt" -ge "$max_attempts" ]; then + echo "npm $NPM_TAG still reports older v$delivered after $attempt attempts; expected v$SEMVER" >&2 + exit 1 + fi + echo "npm $NPM_TAG still reports older v$delivered; waiting for registry propagation ($attempt/$max_attempts)." + sleep 5 + attempt=$((attempt + 1)) + done - name: Sync release artifacts to China OSS mirror + if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }} run: ./scripts/release/sync-to-oss.sh env: VERSION: ${{ needs.release-contract.outputs.release_version }} @@ -1308,14 +1728,14 @@ jobs: OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} OSS_BUCKET: ${{ secrets.OSS_BUCKET }} OSS_PREFIX: ${{ secrets.OSS_PREFIX }} - DWS_REQUIRE_OSS: ${{ github.repository_owner == 'DingTalk-Real-AI' && '1' || '0' }} + DWS_REQUIRE_OSS: "1" mirror-gitee-release: name: Mirror immutable release to Gitee if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }} needs: [release-contract, release, publish-channels] runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 360 permissions: contents: read env: @@ -1332,18 +1752,21 @@ jobs: - name: Check out trusted release tooling uses: actions/checkout@v4 + timeout-minutes: 5 with: ref: ${{ github.sha }} path: tmp/trusted-release-tooling persist-credentials: false - name: Fetch and verify sealed release tag + timeout-minutes: 5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" @@ -1355,7 +1778,7 @@ jobs: path: dist - name: Mirror release to Gitee (China) - timeout-minutes: 100 + timeout-minutes: 318 run: ./scripts/release/sync-to-gitee.sh env: VERSION: ${{ needs.release-contract.outputs.release_version }} @@ -1403,6 +1826,17 @@ jobs: script: | const { owner, repo } = context.repo; const version = process.env.VERSION; + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`${version} was withdrawn and cannot be repaired`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } const ref = await github.rest.git.getRef({ owner, repo, @@ -1469,100 +1903,18 @@ jobs: core.setFailed(`${version} does not contain the exact release asset set`); return; } - const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: "release.yml", - branch: version, - event: "push", - status: "completed", - per_page: 100, - }); - let passed = false; - for (const run of runs) { - if (run.head_sha !== commitSha || run.head_branch !== version) continue; - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { - owner, - repo, - run_id: run.id, - filter: "all", - per_page: 100, - }); - if ( - jobs.some( - (job) => - job.name === "Publish immutable GitHub Release" && - job.head_sha === commitSha && - job.steps?.some( - (step) => - step.name === "Require immutable published GitHub Release" && - step.conclusion === "success", - ), - ) - ) { - passed = true; - break; - } - } - if (!passed) { - const recoveryTitle = `Release recovery ${version} at ${commitSha}`; - const recoveries = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: "release.yml", - branch: defaultBranch, - event: "workflow_dispatch", - status: "completed", - per_page: 100, - }); - for (const run of recoveries) { - const nonce = run.display_title.startsWith(`${recoveryTitle} `) - ? run.display_title.slice(recoveryTitle.length + 1) - : ""; - if ( - !new RegExp(`^${commitSha}-[0-9]+-[0-9]+$`).test(nonce) || - run.head_branch !== defaultBranch || - run.path !== ".github/workflows/release.yml" || - run.repository.full_name !== `${owner}/${repo}` - ) { - continue; - } - const workflowComparison = await github.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `${run.head_sha}...${branch.data.commit.sha}`, - }); - if (!["ahead", "identical"].includes(workflowComparison.data.status)) continue; - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { - owner, - repo, - run_id: run.id, - filter: "all", - per_page: 100, - }); - if ( - jobs.some( - (job) => - job.name === "Publish immutable GitHub Release" && - job.head_sha === run.head_sha && - job.steps?.some( - (step) => - step.name === "Require immutable published GitHub Release" && - step.conclusion === "success", - ), - ) - ) { - passed = true; - break; - } - } - } - if (!passed) { - core.setFailed(`Immutable GitHub Release job did not succeed for ${version} at ${commitSha}`); - return; - } core.setOutput("commit_sha", commitSha); + - name: Require successful Release workflow delivery + working-directory: tooling + env: + VERSION: ${{ inputs.repair_npm_version }} + RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./scripts/release/verify-release-workflow-delivery.sh --npm-repair \ + "$VERSION" "$RELEASE_COMMIT" + - name: Check out target release source uses: actions/checkout@v4 with: @@ -1662,6 +2014,9 @@ jobs: - publish-channels - mirror-gitee-release - repair-npm + - repair-channel + - release-plan + - seal-release runs-on: ubuntu-latest timeout-minutes: 2 permissions: {} @@ -1681,6 +2036,9 @@ jobs: PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }} MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }} REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }} + REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }} + RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }} + SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }} run: | set -eu require_result() { @@ -1704,19 +2062,52 @@ jobs: require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped fi } + require_cloud_jobs_skipped() { + require_result release-plan "$RELEASE_PLAN_RESULT" skipped + require_result seal-release "$SEAL_RELEASE_RESULT" skipped + } case "$EVENT_NAME:$DISPATCH_MODE" in push:) require_result dispatch-contract "$DISPATCH_RESULT" skipped require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped require_publication ;; + workflow_dispatch:create_release) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success + require_result release-plan "$RELEASE_PLAN_RESULT" success + require_result seal-release "$SEAL_RELEASE_RESULT" success + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_publication + ;; + workflow_dispatch:plan_release) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result release-plan "$RELEASE_PLAN_RESULT" success + require_result seal-release "$SEAL_RELEASE_RESULT" skipped + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped + require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped + require_result release "$RELEASE_RESULT" skipped + require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped + require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped + require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + ;; workflow_dispatch:recover_release) require_result dispatch-contract "$DISPATCH_RESULT" success require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped require_publication ;; workflow_dispatch:governance_preflight) @@ -1730,6 +2121,8 @@ jobs: require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped ;; workflow_dispatch:repair_npm) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1742,9 +2135,648 @@ jobs: require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped + ;; + workflow_dispatch:repair_gitee) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result repair-channel "$REPAIR_CHANNEL_RESULT" success + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped + require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped + require_result release "$RELEASE_RESULT" skipped + require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped + require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped + require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_cloud_jobs_skipped + ;; + workflow_dispatch:repair_oss) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result repair-channel "$REPAIR_CHANNEL_RESULT" success + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped + require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped + require_result release "$RELEASE_RESULT" skipped + require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped + require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped + require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_cloud_jobs_skipped ;; *) echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2 exit 1 ;; esac + # Repair one downstream channel from an existing public immutable release on + # demand. The push-triggered channel jobs depend on the original run's + # finalized dist artifact, so a tag that was already published cannot reuse + # it. This dispatch path re-derives the asset set from the immutable GitHub + # Release itself. The release tag, delivery provenance, and asset checksums + # remain the sealed authority boundary. + repair-channel: + name: Repair an existing immutable release channel + needs: dispatch-contract + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }} + runs-on: ubuntu-latest + timeout-minutes: 360 + permissions: + actions: read + contents: read + steps: + - name: Check out trusted release tooling + uses: actions/checkout@v4 + timeout-minutes: 5 + with: + ref: ${{ github.sha }} + path: tooling + persist-credentials: false + fetch-depth: 0 + + - name: Validate repair version + timeout-minutes: 2 + working-directory: tooling + env: + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + run: | + set -eu + . ./scripts/release/release-lib.sh + if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then + echo "Invalid release version: $VERSION" >&2 + exit 2 + fi + + - name: Verify immutable release authority + id: authority + uses: actions/github-script@v7 + timeout-minutes: 5 + env: + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + with: + script: | + const { owner, repo } = context.repo; + const version = process.env.VERSION; + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`${version} was withdrawn and cannot be repaired`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + const ref = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${version}`, + }); + if (ref.data.object.type !== "tag") { + core.setFailed(`${version} is not an annotated tag`); + return; + } + const tag = await github.rest.git.getTag({ + owner, + repo, + tag_sha: ref.data.object.sha, + }); + if (tag.data.tag !== version || tag.data.object.type !== "commit") { + core.setFailed(`${version} does not resolve to one annotated commit tag`); + return; + } + const tagFields = new Map(); + for (const line of tag.data.message.split(/\r?\n/)) { + const separator = line.indexOf(": "); + if (separator < 1) continue; + const key = line.slice(0, separator); + if (tagFields.has(key)) { + core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`); + return; + } + tagFields.set(key, line.slice(separator + 2)); + } + const ossMirror = tagFields.has("OSS-Mirror") + ? tagFields.get("OSS-Mirror") + : "enabled"; + if (!["enabled", "deferred"].includes(ossMirror)) { + core.setFailed(`${version} contains invalid OSS-Mirror metadata`); + return; + } + const commitSha = tag.data.object.sha; + const defaultBranch = context.payload.repository.default_branch; + const branch = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${commitSha}...${branch.data.commit.sha}`, + }); + if (!["ahead", "identical"].includes(comparison.data.status)) { + core.setFailed(`${version} is not contained in ${defaultBranch}`); + return; + } + const release = await github.rest.repos.getReleaseByTag({ + owner, + repo, + tag: version, + }); + if ( + release.data.tag_name !== version || + release.data.draft || + !release.data.immutable + ) { + core.setFailed(`${version} is not a public immutable GitHub Release`); + return; + } + const expectedPrerelease = version.includes("-beta."); + if (release.data.prerelease !== expectedPrerelease) { + core.setFailed(`${version} prerelease metadata does not match its version`); + return; + } + const expectedAssets = new Set([ + "dws-darwin-amd64.tar.gz", + "dws-darwin-arm64.tar.gz", + "dws-linux-amd64.tar.gz", + "dws-linux-arm64.tar.gz", + "dws-windows-amd64.zip", + "dws-windows-arm64.zip", + "dws-skills.zip", + "checksums.txt", + ]); + const assetNames = release.data.assets.map((asset) => asset.name); + if ( + assetNames.length !== expectedAssets.size || + assetNames.some((name) => !expectedAssets.has(name)) || + new Set(assetNames).size !== expectedAssets.size + ) { + core.setFailed(`${version} does not contain the exact release asset set`); + return; + } + core.setOutput("commit_sha", commitSha); + core.setOutput("tag_object", ref.data.object.sha); + core.setOutput("oss_mirror", ossMirror); + + - name: Require sealed OSS policy for repair + if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }} + env: + OSS_MIRROR: ${{ steps.authority.outputs.oss_mirror }} + run: | + test "$OSS_MIRROR" = enabled || { + echo "OSS repair is unavailable because this immutable release deferred the OSS channel." >&2 + exit 1 + } + + - name: Check out sealed release source + uses: actions/checkout@v4 + timeout-minutes: 5 + with: + ref: ${{ steps.authority.outputs.commit_sha }} + path: release-source + persist-credentials: false + + - name: Fetch and verify sealed release tag + timeout-minutes: 5 + working-directory: tooling + env: + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' + ./scripts/release/verify-github-tag-authority.sh \ + "$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" + + - name: Require successful Release workflow delivery + timeout-minutes: 5 + working-directory: tooling + env: + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + REPAIR_MODE: ${{ needs.dispatch-contract.outputs.mode }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + case "$REPAIR_MODE" in + repair_gitee) target=gitee ;; + repair_oss) target=oss ;; + *) echo "Unsupported channel repair mode: $REPAIR_MODE" >&2; exit 2 ;; + esac + ./scripts/release/verify-release-workflow-delivery.sh \ + --channel-repair "$target" "$VERSION" "$RELEASE_COMMIT" + + - name: Download and verify immutable GitHub Release assets + timeout-minutes: 10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + run: | + set -eu + mkdir -p dist + gh release download "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist \ + --pattern 'dws-*' \ + --pattern 'checksums.txt' \ + --clobber + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION" + + - name: Mirror release to Gitee (China) + if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }} + timeout-minutes: 318 + working-directory: tooling + run: | + "$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh" + env: + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + DIST_DIR: ${{ github.workspace }}/dist + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_USER: ${{ secrets.GITEE_USER }} + GITEE_REPO: ${{ secrets.GITEE_REPO }} + DWS_REQUIRE_GITEE: "1" + + - name: Mirror release to OSS (China) + if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }} + timeout-minutes: 30 + working-directory: release-source + run: | + "$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-oss.sh" + env: + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + DIST_DIR: ${{ github.workspace }}/dist + OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} + OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_PREFIX: ${{ secrets.OSS_PREFIX }} + DWS_REQUIRE_OSS: "1" + + release-plan: + name: Plan next cloud release + needs: [dispatch-contract, governance-preflight] + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'plan_release' || (needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success')) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + outputs: + release_version: ${{ steps.allocate.outputs.release_version }} + release_commit: ${{ steps.allocate.outputs.release_commit }} + channel: ${{ steps.allocate.outputs.channel }} + from_beta: ${{ steps.allocate.outputs.from_beta }} + base: ${{ steps.allocate.outputs.base }} + refs_fingerprint: ${{ steps.allocate.outputs.refs_fingerprint }} + oss_mirror: ${{ steps.allocate.outputs.oss_mirror }} + steps: + - name: Validate official default-branch release request + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli + run: | + set -eu + test "$GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY" || { + echo "cloud releases are restricted to $EXPECTED_REPOSITORY" >&2 + exit 1 + } + test "$GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH" || { + echo "cloud releases must be dispatched from $DEFAULT_BRANCH" >&2 + exit 1 + } + printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' || { + echo "cloud release commit must be a full lowercase SHA" >&2 + exit 1 + } + + - name: Check out exact default-branch commit + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Fetch current main and all allocated versions + run: | + set -eu + git fetch --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" || { + echo "checked-out release commit differs from the dispatched commit" >&2 + exit 1 + } + test "$(git rev-parse refs/remotes/origin/main)" = "$GITHUB_SHA" || { + echo "main moved after this release was dispatched; run it again from current main" >&2 + exit 1 + } + + - name: Allocate the next version + id: allocate + env: + REQUESTED_CHANNEL: ${{ inputs.release_channel }} + REQUESTED_BUMP: ${{ inputs.release_bump }} + OSS_MIRROR: ${{ vars.ENABLE_OSS_MIRROR == 'true' && 'enabled' || 'deferred' }} + run: | + set -eu + allocation="$RUNNER_TEMP/next-release-version" + ./scripts/release/next-release-version.sh \ + --channel "$REQUESTED_CHANNEL" \ + --bump "$REQUESTED_BUMP" > "$allocation" + for key in release_version from_beta channel base; do + count="$(grep -c "^$key=" "$allocation")" + test "$count" -eq 1 || { + echo "allocator returned $count values for $key" >&2 + exit 1 + } + value="$(sed -n "s/^$key=//p" "$allocation")" + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" + done + refs_unsorted="$RUNNER_TEMP/release-refs.unsorted" + refs_manifest="$RUNNER_TEMP/release-refs" + git for-each-ref --format='%(refname)=%(objectname)' \ + 'refs/tags/v*' 'refs/tags/withdrawn/v*' > "$refs_unsorted" + LC_ALL=C sort "$refs_unsorted" > "$refs_manifest" + test -s "$refs_manifest" || { + echo "release ref manifest is empty after fetching allocated tags" >&2 + exit 1 + } + refs_fingerprint="$(sha256sum "$refs_manifest" | awk '{print $1}')" + echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT" + case "$OSS_MIRROR" in enabled|deferred) ;; *) exit 1 ;; esac + echo "oss_mirror=$OSS_MIRROR" >> "$GITHUB_OUTPUT" + + release_version="$(sed -n 's/^release_version=//p' "$allocation")" + from_beta="$(sed -n 's/^from_beta=//p' "$allocation")" + channel="$(sed -n 's/^channel=//p' "$allocation")" + { + echo "## Cloud release plan" + echo + echo "- Version: \`$release_version\`" + echo "- Channel: \`$channel\`" + echo "- Commit: \`$GITHUB_SHA\`" + echo "- OSS mirror: \`$OSS_MIRROR\`" + if test -n "$from_beta"; then + echo "- Promotes beta: \`$from_beta\`" + fi + if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then + echo + echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Validate the candidate release contract before sealing + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }} + env: + RELEASE_VERSION: ${{ steps.allocate.outputs.release_version }} + RELEASE_CHANNEL: ${{ steps.allocate.outputs.channel }} + FROM_BETA: ${{ steps.allocate.outputs.from_beta }} + OSS_MIRROR: ${{ steps.allocate.outputs.oss_mirror }} + run: | + set -eu + git config user.name "dws release cloud seal" + git config user.email "dws-release@users.noreply.github.com" + { + printf 'Release %s\n\n' "$RELEASE_VERSION" + printf 'Channel: %s\n' "$RELEASE_CHANNEL" + test -z "$FROM_BETA" || printf 'From-Beta: %s\n' "$FROM_BETA" + printf 'OSS-Mirror: %s\n' "$OSS_MIRROR" + } > "$RUNNER_TEMP/candidate-tag-message" + git tag -a "$RELEASE_VERSION" HEAD -F "$RUNNER_TEMP/candidate-tag-message" + set -- \ + --channel "$RELEASE_CHANNEL" \ + --version "$RELEASE_VERSION" \ + --context ci \ + --remote origin \ + --branch main \ + --notes-output "$RUNNER_TEMP/release-notes.md" \ + --metadata-output "$RUNNER_TEMP/release-contract-output" + if test -n "$FROM_BETA"; then + set -- "$@" --from-beta "$FROM_BETA" + fi + ./scripts/release/release-contract.sh "$@" + + - name: Require delivered previous stable baseline before sealing + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + previous_stable="$(sed -n 's/^previous_stable=//p' "$RUNNER_TEMP/release-contract-output")" + previous_commit="$(sed -n 's/^previous_stable_commit=//p' "$RUNNER_TEMP/release-contract-output")" + if test -n "$previous_stable"; then + ./scripts/release/verify-delivered-stable.sh "$previous_stable" "$previous_commit" + fi + + - name: Require delivered beta before sealing stable + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && steps.allocate.outputs.channel == 'stable' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FROM_BETA: ${{ steps.allocate.outputs.from_beta }} + run: | + set -eu + from_beta_commit="$(git rev-parse "$FROM_BETA^{commit}")" + release_state="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$FROM_BETA" \ + --jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv' + )" + test "$release_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" || { + echo "$FROM_BETA is not a published immutable prerelease" >&2 + exit 1 + } + ./scripts/release/verify-github-release-assets.sh "$FROM_BETA" + ./scripts/release/verify-release-workflow-delivery.sh \ + "$FROM_BETA" "$from_beta_commit" + + seal-release: + name: Seal cloud release tag + needs: [dispatch-contract, governance-preflight, release-plan] + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + outputs: + release_version: ${{ steps.seal.outputs.release_version }} + release_commit: ${{ steps.seal.outputs.release_commit }} + release_tag_object: ${{ steps.seal.outputs.release_tag_object }} + steps: + - name: Create one immutable annotated release tag + id: seal + uses: actions/github-script@v7 + env: + RELEASE_VERSION: ${{ needs.release-plan.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-plan.outputs.release_commit }} + RELEASE_CHANNEL: ${{ needs.release-plan.outputs.channel }} + FROM_BETA: ${{ needs.release-plan.outputs.from_beta }} + REFS_FINGERPRINT: ${{ needs.release-plan.outputs.refs_fingerprint }} + OSS_MIRROR: ${{ needs.release-plan.outputs.oss_mirror }} + with: + script: | + const crypto = require("crypto"); + const { owner, repo } = context.repo; + const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"; + const defaultBranch = context.payload.repository.default_branch; + const version = process.env.RELEASE_VERSION; + const commit = process.env.RELEASE_COMMIT; + const channel = process.env.RELEASE_CHANNEL; + const fromBeta = process.env.FROM_BETA; + const expectedFingerprint = process.env.REFS_FINGERPRINT; + const ossMirror = process.env.OSS_MIRROR; + + if ( + context.payload.repository.full_name !== expectedRepository || + context.ref !== `refs/heads/${defaultBranch}` || + context.sha !== commit + ) { + core.setFailed("cloud release seal is not bound to the official dispatched default-branch commit"); + return; + } + if ( + !/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version) || + !/^[0-9a-f]{40}$/.test(commit) || + !/^[0-9a-f]{64}$/.test(expectedFingerprint) || + !["enabled", "deferred"].includes(ossMirror) + ) { + core.setFailed("cloud release plan contains an invalid sealed identity"); + return; + } + if ( + (channel === "stable" && !fromBeta) || + (channel === "prerelease" && fromBeta) + ) { + core.setFailed("cloud release channel and beta promotion baseline disagree"); + return; + } + + const branch = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + if (branch.data.commit.sha !== commit) { + core.setFailed(`${defaultBranch} moved to ${branch.data.commit.sha}; run the release again`); + return; + } + + const refs = []; + for (const prefix of ["tags/v", "tags/withdrawn/v"]) { + const matching = await github.paginate(github.rest.git.listMatchingRefs, { + owner, + repo, + ref: prefix, + per_page: 100, + }); + for (const ref of matching) { + refs.push(`${ref.ref}=${ref.object.sha}`); + } + } + refs.sort(); + const actualFingerprint = crypto + .createHash("sha256") + .update(refs.length > 0 ? `${refs.join("\n")}\n` : "") + .digest("hex"); + if (actualFingerprint !== expectedFingerprint) { + core.setFailed("allocated release refs changed after planning; run the release again"); + return; + } + + try { + await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }); + core.setFailed(`release tag already exists: ${version}`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`release version was withdrawn and cannot be reused: ${version}`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + const message = [ + `Release ${version}`, + "", + `Channel: ${channel}`, + ...(fromBeta ? [`From-Beta: ${fromBeta}`] : []), + `OSS-Mirror: ${ossMirror}`, + `Release-Run: ${context.runId}`, + `Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`, + `Requested-By: ${context.actor}`, + `Requested-By-ID: ${context.payload.sender?.id || ""}`, + `Sealed-Commit: ${commit}`, + `Workflow-Commit: ${context.sha}`, + `Allocation-Fingerprint: ${expectedFingerprint}`, + ].join("\n"); + const tag = await github.rest.git.createTag({ + owner, + repo, + tag: version, + message, + object: commit, + type: "commit", + }); + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${version}`, + sha: tag.data.sha, + }); + + const sealedRef = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${version}`, + }); + if ( + sealedRef.data.object.type !== "tag" || + sealedRef.data.object.sha !== tag.data.sha + ) { + core.setFailed(`${version} did not seal to the new annotated tag object`); + return; + } + const sealedTag = await github.rest.git.getTag({ + owner, + repo, + tag_sha: tag.data.sha, + }); + if ( + sealedTag.data.tag !== version || + sealedTag.data.object.type !== "commit" || + sealedTag.data.object.sha !== commit || + sealedTag.data.message !== message + ) { + core.setFailed(`${version} annotated tag authority differs after creation`); + return; + } + + core.setOutput("release_version", version); + core.setOutput("release_commit", commit); + core.setOutput("release_tag_object", tag.data.sha); + await core.summary + .addHeading("Cloud release sealed") + .addRaw(`\`${version}\` → \`${commit}\` (run ${context.runId})`) + .write(); diff --git a/.github/workflows/withdraw-release.yml b/.github/workflows/withdraw-release.yml new file mode 100644 index 00000000..042f32e3 --- /dev/null +++ b/.github/workflows/withdraw-release.yml @@ -0,0 +1,148 @@ +name: Withdraw release +run-name: Withdraw ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)" + required: true + type: string + reason: + description: "Public, single-line withdrawal reason (8-300 characters)" + required: true + type: string + confirmation: + description: "Type WITHDRAW followed by a space and the exact version" + required: true + type: string + +permissions: + contents: read + +# Share the publication lock with release.yml. A withdrawal and a publication +# must never mutate channel pointers concurrently. +concurrency: + group: dws-release-publication + cancel-in-progress: false + +jobs: + withdraw: + name: Withdraw release from every distribution channel + environment: release-withdrawal + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + actions: read + contents: write + steps: + - name: Verify withdrawal environment protection + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const response = await github.request( + "GET /repos/{owner}/{repo}/environments/{environment_name}", + { owner, repo, environment_name: "release-withdrawal" }, + ); + const reviewerRule = response.data.protection_rules.find( + (rule) => rule.type === "required_reviewers", + ); + if ( + !reviewerRule || + reviewerRule.prevent_self_review !== true || + !Array.isArray(reviewerRule.reviewers) || + reviewerRule.reviewers.length === 0 + ) { + core.setFailed("release-withdrawal must require a reviewer and prevent self-review"); + return; + } + if (response.data.deployment_branch_policy?.protected_branches !== true) { + core.setFailed("release-withdrawal must allow only protected branches"); + } + if (response.data.can_admins_bypass !== false) { + core.setFailed("release-withdrawal must not allow administrator bypass"); + } + + - name: Require the exact current official default-branch commit + uses: actions/github-script@v7 + with: + script: | + const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"; + const defaultBranch = context.payload.repository.default_branch; + if (context.eventName !== "workflow_dispatch") { + core.setFailed("release withdrawal accepts workflow_dispatch only"); + return; + } + if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) { + core.setFailed(`release withdrawal is restricted to ${expectedRepository}`); + return; + } + if (context.ref !== `refs/heads/${defaultBranch}`) { + core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`); + return; + } + const branch = await github.rest.git.getRef({ + ...context.repo, + ref: `heads/${defaultBranch}`, + }); + if (branch.data.object.sha !== context.sha) { + core.setFailed( + `default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`, + ); + } + + - name: Check out trusted withdrawal tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js for npm channel withdrawal + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Withdraw immutable release and roll back channels + id: withdrawal + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WITHDRAW_VERSION: ${{ inputs.version }} + WITHDRAW_REASON: ${{ inputs.reason }} + WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }} + OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} + OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_PREFIX: ${{ secrets.OSS_PREFIX }} + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_USER: ${{ secrets.GITEE_USER }} + GITEE_REPO: ${{ secrets.GITEE_REPO }} + DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }} + HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }} + run: | + ./scripts/release/withdraw-release.sh \ + "$WITHDRAW_VERSION" \ + "$WITHDRAW_REASON" \ + "$WITHDRAW_CONFIRMATION" + + - name: Report withdrawal boundary + if: ${{ always() }} + env: + VERSION: ${{ inputs.version }} + RESULT: ${{ steps.withdrawal.outcome }} + run: | + { + echo "### Release withdrawal: ${VERSION}" + echo + echo "- Workflow result: ${RESULT}" + echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier." + echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause." + echo "- The problem GitHub Release and original tag are removed after npm and every tag-enabled/configured mirror are rolled back, so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed." + echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded." + echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 954c0625..5551f325 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,15 @@ test/dev_functional/results.jsonl /coverage-policy.txt /coverage.html dwsbin + +# Local shortcut eval / real-backend capture artifacts — may contain real PII +# (employee names/emails, userIds, conversation & message IDs). Never commit. +/docs/shortcut-real-read-results.json +/docs/shortcut-real-write-results.json +/docs/shortcut-comparison.html +/docs/shortcut-gsb-eval.* +/scripts/run_shortcut_real_read_matrix.py + +# Local coverage artifacts +coverage-shortcut.txt +coverage-*.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c2843b..46d450d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,108 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.55-beta.1] - 2026-07-23 + +This beta validates MCP Market URL resolution, the supported Wukong local-file +send path after retiring the legacy credential-based media upload command from +discovery, and reliable message-read rendering for rich content, forwarded +records, encrypted messages, and media-download ID aliases. + +### Added + +- **MCP URL resolution** — adds `dws mcp url get ` for resolving a DingTalk MCP Market ID to the current user and organization scoped Streamable HTTP URL, while keeping the helper-only `mcp-meta` endpoint out of the public product command surface. + +### Changed + +- **Chat local-file sending** — hides the open-source-only `chat media upload` compatibility command from Help, Schema, and bundled Skills, and removes its legacy AppKey/AppSecret OAPI path. Historical argv still receives an actionable migration error. Send local images and files through `chat message send --msg-type file --file-path`; callers that already hold a mediaId may continue to use `--msg-type image --media-id`. + +### Fixed + +- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag". + +## [1.0.54] - 2026-07-21 + +This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes. + +### Changed + +- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`. + +### Fixed + +- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution. +- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict. + +## [1.0.54-beta.2] - 2026-07-21 + +This beta revalidates the same `v1.0.54-beta.1` source through the cloud release path with a sealed `OSS-Mirror: deferred` policy, because the manually tagged `v1.0.54-beta.1` push run failed on the unavailable OSS mirror channel after GitHub and npm delivery. + +### Changed + +- **Release delivery only** — no source changes since `v1.0.54-beta.1`; see that section for the user-visible changes under validation (#743, #738, #701). + +## [1.0.54-beta.1] - 2026-07-21 + +This beta validates the restored default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes, on top of the validated `v1.0.53-beta.7` baseline. + +### Changed + +- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`. + +### Fixed + +- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution. +- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict. + +## [1.0.53] - 2026-07-21 + +This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path. + +### Added + +- **Enterprise and office command coverage** — adds enterprise creation, employee invitation, and account provisioning commands; 366 declarative service shortcuts; Sheet import commands; and Aitable workflow create/update support with reviewed Schema contracts. +- **Multiple accounts in one DingTalk organization** — profiles can distinguish accounts by organization and user, select them explicitly, and log out one account or an entire organization without overwriting another account's credentials. +- **Expanded personal IM event subscriptions** (#651) — adds read-receipt, recall, and reaction events for one-to-one and group chats, plus specified-sender subscriptions by staff ID or OpenDingTalk ID. +- **Official multi-platform Homebrew channel** — ships separate stable and keg-only beta Formulae for macOS and Linux across amd64 and arm64, with isolated update PRs. + +### Changed + +- **Personal event output contract** (#651) — `event consume` now emits event-specific top-level structured fields; scripts that consumed the former transport envelope must use the flat fields or select `-f raw`, while `--debug-raw-events` retains the diagnostic envelope. +- **Guarded release lifecycle** — beta/stable publication now uses explicit promotion, immutable delivery proofs, protected recovery, and tag-bound optional OSS policy; an unprovisioned OSS mirror is sealed as `deferred` so GitHub, npm, and Homebrew are not blocked. +- **Relaxed stable promotion contract** (#729) — a stable release still requires a delivered, non-withdrawn beta baseline in its commit history, but no longer requires a byte-identical tree with that beta; reviewed commits merged to `main` after the beta can now ship in the stable release. Local releases now accept any sealed commit contained in `main` history and push only the release tag, so `main` is never frozen during the beta-to-stable window. + +### Fixed + +- **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles. +- **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size. +- **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, cloud planning fingerprints the actual allocated release refs, and npm channel verification waits for bounded registry propagation without moving tags. +- **Package-manager version verification** (#735) — npm-vendored, Homebrew-installed, and packaged release binaries are now verified by searching their raw bytes for the injected version marker, so a correctly versioned stable binary is no longer rejected when the short version marker coalesces with adjacent printable linker metadata; incorrect or missing markers still fail closed. + +## [1.0.53-beta.7] - 2026-07-21 + +This beta validates bounded npm channel verification after registry publication. + +### Fixed + +- **npm dist-tag eventual consistency** — Release delivery now tolerates a briefly stale `latest` or `beta` read after publishing by retrying only when npm reports a valid older version. Registry errors, invalid or incomparable tags, and channels that never converge still fail closed without moving any tag during verification. + +## [1.0.53-beta.6] - 2026-07-21 + +This beta validates guarded local release compatibility and tag-bound OSS deferral so an unprovisioned mirror cannot block the primary release channels. + +### Changed + +- **Tag-bound optional OSS release mirror** — Official cloud Release runs no longer block GitHub, npm, and Homebrew delivery when an OSS bucket has not been provisioned. Cloud tags immutably record `OSS-Mirror: enabled|deferred`; publication, repair, and withdrawal consume that sealed policy instead of the current repository variable. Enabled releases remain fail-closed, while deferred releases skip the nonexistent channel and cannot be backfilled without a future audited repair proof. + +### Fixed + +- **Guarded local release compatibility** — The tag-push Release workflow now accepts the `Channel`-only annotated tags created by the guarded local release entry while continuing to reject any partial cloud-only seal metadata. +- **Cloud release tag allocation fingerprint** — Release planning now fingerprints the actual `v*` and `withdrawn/v*` refs fetched from GitHub, matching the seal job's API view instead of hashing an empty non-wildcard ref prefix and rejecting every publish before tag creation. + +## [1.0.53-beta.5] - 2026-07-21 + +This beta validates long-running access-token recovery and the faster, recoverable guarded release path introduced after v1.0.53-beta.4. + ### Changed - **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated. @@ -13,6 +115,9 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed +- **Long-running event authentication recovery** — personal and portal event streams resolve the current access token for every ticket request, refresh a server-rejected token with compare-and-refresh semantics, and reconnect with backoff when refresh is temporarily blocked by network failures, rate limits, or 5xx responses. +- **Consistent access-token caching and errors** — runtime, recovery, Skill, PAT polling, and personal/portal event clients now resolve user access tokens through one expiry- and publication-aware manager, so long-running processes reload rotated credentials while keychain, refresh, parse, permission, and cancellation failures remain observable instead of being collapsed into “not authenticated.” +- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery. - **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget. ## [1.0.53-beta.4] - 2026-07-17 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7eff27..38b9e684 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,8 +36,8 @@ Common repository checks already used here include: ./scripts/policy/check-open-source-assets.sh go test ./... make test +make test-plan make lint -bash test/scripts/run_all_tests.sh --jobs 8 ./scripts/policy/check-generated-drift.sh ./scripts/policy/check-command-surface.sh --strict ./scripts/release/verify-package-managers.sh diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index acb98ccf..8deca37e 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.53-beta.2" + version "1.0.54-beta.2" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-arm64.tar.gz" - sha256 "47d3f470003a309f4a93a4dfe55ab39240018b7c698f7863d85834e1f0a3affa" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-arm64.tar.gz" + sha256 "46b57bed1f6e9f7ba007d8a86a6f5eb280fdeb557fc9bb5946f14f9b1f8f0c9f" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-amd64.tar.gz" - sha256 "2dcf90b515d934e71715d95098d3b3cec34299cdbe6c858e1106a81d23d3d51a" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-amd64.tar.gz" + sha256 "1b7fd08e64b1c86bbcee217604ffe07e0e8f1b3b5c4de518534386972bcf0f9b" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-arm64.tar.gz" - sha256 "b4692a3c2690460c039e641f75f6793daf3b8549b8c9a35d01153a908f6cc2b1" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-arm64.tar.gz" + sha256 "108d3861ef606519f9934530d29654eab55a73607d1ee6775461f98ef5a6acd4" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-amd64.tar.gz" - sha256 "000d29d4c81589553e5b23b573002b7014b16c073793b8d7c5617e9b89488175" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-amd64.tar.gz" + sha256 "6cb96ee09419bbbcc1eb336218ac2aa1d9ca0ed5cbd5a80c79bc20e1e1f03ff7" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-skills.zip" - sha256 "b55a4eaaa63073147c3b9efff48b4713be75577c8a1d2dc8c6e11dc30b4f91c8" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-skills.zip" + sha256 "572b93f04a10268d185ad1f8e70e0d412949ae056be8494a9949387076fd14bc" end def install diff --git a/Formula/dingtalk-workspace-cli.rb b/Formula/dingtalk-workspace-cli.rb index 0416b9a1..c6846a01 100644 --- a/Formula/dingtalk-workspace-cli.rb +++ b/Formula/dingtalk-workspace-cli.rb @@ -1,32 +1,33 @@ class DingtalkWorkspaceCli < Formula desc "Automate DingTalk workspace tasks from the terminal" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.52" + version "1.0.54" license "Apache-2.0" + on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz" - sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz" + sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz" - sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz" + sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz" - sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz" + sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz" - sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz" + sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip" - sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip" + sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0" end def install @@ -52,6 +53,7 @@ class DingtalkWorkspaceCli < Formula <<~EOS Agent Skills are bundled in #{pkgshare}/skills/dws. Run `dws skill setup` to install them into your Agent directories. + EOS end diff --git a/Makefile b/Makefile index 3f859ba5..7a0fffd6 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ GO ?= go +DWS_PACKAGE_VERSION ?= 0.0.0-test REMOTE ?= PUBLISH ?= 0 YES ?= 0 DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" +GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -14,13 +16,15 @@ help: @printf "Available targets:\n" @printf " make build - Build the dws CLI binary\n" @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 test-plan - Verify every default Go package belongs to one CI test shard\n" + @printf " make lint - Run formatting checks, go vet, and staticcheck\n" + @printf " make format-check - Check all repository Go source files with gofmt\n" + @printf " make fmt - Format all repository Go source files\n" @printf " make policy - Check the built dws plus open-source and Schema policies\n" @printf " make interface-integrity - Check historical commands and help contracts still work\n" @printf " make authoritative-interface-integrity BASE_REF= - Check the Git-owned PR merge-base\n" - @printf " make coverage-gate BASE_REF= - Enforce overall non-regression and changed-code coverage\n" - @printf " make coverage-gate-platform BASE_REF= PROFILE= - Enforce native-platform changed-code coverage\n" + @printf " make coverage-gate BASE_REF= - Enforce overall non-regression and 100%% changed-code coverage\n" + @printf " make coverage-gate-platform BASE_REF= PROFILE= - Enforce 100%% native changed-code coverage\n" @printf " make update-interface-baseline - Add new CLI contracts without removing history\n" @printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n" @printf " make schema-compatibility BASE_REF= - Check the complete Schema contract against the PR merge-base\n" @@ -45,13 +49,32 @@ rebuild: @./scripts/dev/build.sh test: - @./test/scripts/run_all_tests.sh --timeout 5m + @DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./... + +test-plan: + @./scripts/ci/test-packages.sh verify lint: @./scripts/dev/lint.sh +format-check: + @set -eu; \ + go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \ + trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \ + $(GO_SOURCE_LIST) > "$$go_files"; \ + unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \ + if [ -n "$$unformatted" ]; then \ + printf '%s\n' "$$unformatted"; \ + printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \ + exit 1; \ + fi + fmt: - @find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w + @set -eu; \ + go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \ + trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \ + $(GO_SOURCE_LIST) > "$$go_files"; \ + xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files" policy: @mkdir -p "$(POLICY_GOTMPDIR)" diff --git a/README.md b/README.md index fde02fa1..121329a6 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,8 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p `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. +The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`. + > **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: @@ -487,19 +489,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace ```bash # Inspect the public personal event catalog and schema dws event list -dws event schema user_im_message_receive_o2o +dws event schema user_im_message_receive_o2o --flatten # Listen for messages that mention the current user -dws event consume user_im_message_receive_at -f ndjson +dws event consume user_im_message_receive_at --flatten -f ndjson # Listen for one-to-one messages with a specified user -dws event consume user_im_message_receive_o2o --user -f ndjson +dws event consume user_im_message_receive_o2o --user --flatten -f ndjson # Listen by openDingtalkId (external contact, bot, or cross-organization identity) -dws event consume user_im_message_receive_o2o --open-dingtalk-id -f ndjson +dws event consume user_im_message_receive_o2o --open-dingtalk-id --flatten -f ndjson # Listen for messages in a specified group -dws event consume user_im_message_receive_group --group -f ndjson +dws event consume user_im_message_receive_group --group --flatten -f ndjson # Inspect local consumers and cancel a subscription dws event status diff --git a/README_zh.md b/README_zh.md index fa9c797f..31ee238f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -470,6 +470,8 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi `dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。 +默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。 + > **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。 只需要 event 能力时,可以使用官方便捷安装脚本: @@ -481,19 +483,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace ```bash # 查看公开个人事件目录和 schema dws event list -dws event schema user_im_message_receive_o2o +dws event schema user_im_message_receive_o2o --flatten # 监听当前用户被 @ 的消息 -dws event consume user_im_message_receive_at -f ndjson +dws event consume user_im_message_receive_at --flatten -f ndjson # 监听与指定用户的单聊消息 -dws event consume user_im_message_receive_o2o --user -f ndjson +dws event consume user_im_message_receive_o2o --user --flatten -f ndjson # 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份 -dws event consume user_im_message_receive_o2o --open-dingtalk-id -f ndjson +dws event consume user_im_message_receive_o2o --open-dingtalk-id --flatten -f ndjson # 监听指定群的消息 -dws event consume user_im_message_receive_group --group -f ndjson +dws event consume user_im_message_receive_group --group --flatten -f ndjson # 查看本地 consume,并取消指定订阅 dws event status diff --git a/docs/architecture.md b/docs/architecture.md index 30dff954..d2e40eb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,3 +43,38 @@ - `skills/`: bundled agent skills (mono/ and multi/ layouts) - `test/`: CLI, integration, contract, unit, and skill E2E tests - `scripts/`: install scripts, policy checks, and CI helpers + +## Quality Pipeline + +Quality enforcement is layered so a pull request receives fast, deterministic +admission feedback without pretending that downstream integration has already +run. + +```mermaid +flowchart TB + PR["Pull request"] --> CA["CI"] + subgraph CA_CHECKS["Nine required contexts"] + L["Lint"] + T["Test"] + C["Coverage"] + P["Policy"] + E["Edition"] + I["Interface Integrity"] + A["AI Behavior"] + S["CLI Smoke"] + M["Mock MCP"] + end + CA --> CA_CHECKS + CA_CHECKS --> MAIN["Protected main"] + MAIN --> MP["Main Integration — 主干集成
Multi-profile E2E"] + MAIN --> PLATFORM["Risk-selected / release native platform validation"] + MP --> RELEASE["Release delivery"] + PLATFORM --> RELEASE +``` + +Complete Multi-profile E2E and the ordinary full native-platform matrix are +downstream of PR admission. PRs still run primary-environment assurance and +fast cross-platform compilation; auth, keychain, OS-specific, installer, and +release changes additionally select native platform tests before merge. See +[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset +contract. diff --git a/docs/automation.md b/docs/automation.md index 8c8a3742..d6d19218 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -108,6 +108,34 @@ commit, and failed tag-push run all match; it then reuses the normal release jobs. Do not put publication secrets in temporary branches or create ad-hoc recovery workflows. +Cloud-sealed releases mirror to OSS only when the repository variable +`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket +is provisioned; GitHub, npm, and Homebrew delivery can then complete without +running the OSS step. Once enabled, missing credentials, an invalid Bucket, or +an upload failure remains fail-closed. The cloud tag immutably records the +decision as `OSS-Mirror: enabled|deferred`; publication and withdrawal consume +that sealed value instead of the variable's later state. Deferred releases +cannot use `repair_oss_version`; enabling OSS applies to later release tags +until an audited immutable repair marker is implemented. + +If an immutable GitHub Release and npm package were delivered but an enabled +downstream China mirror failed, dispatch the normal `Release` workflow from the +protected default branch with exactly one of `repair_gitee_version` or +`repair_oss_version`. Channel repair accepts a fully successful exact release, +or a failed exact-tag run only when its latest attempt completed the release +contract, build, Apple signature, immutable GitHub publication, and npm +delivery checks for the exact tagged commit. OSS repair additionally requires +the tag's sealed policy to be `enabled`. It then downloads and re-verifies the +immutable assets before invoking only the selected mirror. For a failed +release, an OSS repair requires the OSS step itself to be the recorded failure. +A Gitee repair accepts either a failed Gitee job or a Gitee job that was +skipped behind that OSS failure; the latter is an explicit Gitee backfill and +does not claim that OSS has been repaired. Gitee repair requires `GITEE_TOKEN`, +`GITEE_USER`, and `GITEE_REPO`; OSS repair requires `OSS_ACCESS_KEY_ID`, +`OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET` (with optional +`OSS_PREFIX`) as Actions secrets. Missing credentials fail the selected repair +closed. + ## Handoff Checklist Before handoff, include: diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 0a94b945..fc17690a 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -1,133 +1,158 @@ -# Pull request quality gates +# CI — PR 合入门禁 -The repository defines five focused checks in addition to its existing CI: +The pull-request admission layer has exactly nine required external contexts: -- **Interface Integrity** enforces backwards compatibility. Every historical - command path and alias must still resolve, every historical command must - still render `-h`, and historical flags must keep their type and shorthand. - New commands, aliases, and flags are allowed. The same job compares the full - complete `dws schema --all` contract with the PR merge-base, blocking removed - products/tools/parameters, incompatible parameter or interface mappings, - constraint drift, and safety-semantic drift. It also checks that executable - `dws ...` references in `skills/**/*.md` resolve to real commands. - Help compatibility covers command/alias/flag spelling, flag type and - shorthand; descriptive prose may evolve without breaking the gate. -- **Coverage** runs unit tests on every pull request and prints both overall and - changed-code statement coverage. During the migration to the 80% repository - target, overall coverage may not regress from a profile generated from the - merge-base with the same test command, while changed production Go - statements must meet 80%. Linux, Windows, and macOS each generate a native - coverage profile for changed packages and enforce the threshold against - changed files buildable on that platform, so build-tagged source cannot be - hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement - variance to avoid failing unchanged code on test-path noise. Set - `COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make - the overall target fail closed as well. - CI generates the candidate, supporting, and merge-base profiles on three - independent runners, then downloads all profiles into the aggregate - `Coverage` job and applies the same fail-closed gate. The split changes only - scheduling: the tested packages, profile contents, merge-base comparison, - and final coverage thresholds remain unchanged. -- **CLI Smoke** builds the release binary, reads the root command list from the - structured Interface contract, and renders offline help for every public - top-level command. It rejects Cobra's unknown-command root-help fallback and - fails when the checked-in development fixture is stale. -- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests - (`Initialize -> ListTools -> CallTool`). -- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It - limits the change to 30 files and blocks release/CI infrastructure changes, - including policy implementations and the checked-in Interface fixture. - It uses `pull_request_target` without checking out PR code, so the policy - cannot be bypassed by changing the workflow in the same pull request. The - evaluator writes an `AI Behavior Check` commit status to the PR head SHA so - GitHub rulesets can require it. +| Required context | Contract | +|---|---| +| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint | +| `Test` | Race/unit/release-script tests plus fast cross-platform compilation | +| `Coverage` | Overall non-regression and 100% changed-code coverage | +| `Policy` | Repository policy and the fail-closed CHANGELOG contract | +| `Edition` | Edition contract tests | +| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility | +| `AI Behavior` | Base-owned policy for PRs labeled `ai-generated` | +| `CLI Smoke` | Offline help for every public top-level command | +| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests | -## Running the compatibility gates +The workflow display name is `CI`. Parallel helper +jobs may implement `Test` and `Coverage`, but they are not ruleset contexts. +Do not require an aggregate alias or a downstream integration check in place of +the nine contracts above. -Run: +`AI Behavior` is evaluated by a `pull_request_target` workflow that never +checks out or executes PR code. It writes the exact `AI Behavior` status to the +current PR head. Its Files API read is bracketed by base/head revision checks, +so a synchronize race fails closed. The same workflow supplies a successful +`AI Behavior` check run on protected `main` pushes for release governance. + +## Exact CHANGELOG-only fast path + +A pull request qualifies only when GitHub reports exactly one changed file, +that file is an in-place modification of `CHANGELOG.md`, and the base and head +both retain it as a regular non-executable `100644` blob. Add, delete, rename, +symlink, executable-mode, and second-file changes do not qualify. + +`Lint` classifies the Files API result only after verifying that the API's base +and head equal the event revision both before and after pagination. `Policy` +checks out GitHub's PR merge ref and verifies its parents: + +```text +HEAD^1 = pull_request.base.sha +HEAD^2 = pull_request.head.sha +``` + +It then runs: + +```sh +./scripts/policy/check-changelog-pr.sh \ + --fast-path "$PR_BASE_SHA" HEAD +``` + +Because the verified PR diff contains only `CHANGELOG.md`, the validator and +its policy dependencies in that merge tree are byte-for-byte the current base +versions. Validation targets the synthetic merge tree, not the feature-branch +tree, so a stale branch cannot supply an older validator or combine with newer +base notes into an invalid final CHANGELOG. + +All nine admission contexts are still emitted and must succeed. Expensive +implementation helpers are skipped; the named contexts record that their code +surface is unaffected. After merge, the protected `main` push executes the +full admission suite. + +Any PR that touches `CHANGELOG.md` but also changes another file runs the same +content contract in `Policy` with `--content-only`. That mode permits the +second file but still rejects invalid dates or versions, missing bullets, +placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes. +Adding a second file therefore cannot bypass CHANGELOG validation. + +## Platform and downstream boundaries + +Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile +checks. Full native macOS/Windows tests and platform coverage run on a PR only +when its diff touches auth, keychain, OS-specific Go files, installers, +packaging, Formulae, or release automation. Protected `main` pushes run the +complete native matrix. + +Complete `Multi-profile E2E` is not a PR admission context. It belongs to the +`Main Integration — 主干集成` workflow and runs only after a push to `main` (or +an explicit manual dispatch). A failing downstream run remains a real +regression and must be repaired, but it must not be represented by a synthetic +successful PR check. + +```mermaid +flowchart TB + PR["Pull request"] --> ADMISSION["CI"] + ADMISSION --> L["Lint"] + ADMISSION --> T["Test"] + ADMISSION --> C["Coverage"] + ADMISSION --> P["Policy"] + ADMISSION --> E["Edition"] + ADMISSION --> I["Interface Integrity"] + ADMISSION --> A["AI Behavior"] + ADMISSION --> S["CLI Smoke"] + ADMISSION --> M["Mock MCP"] + ADMISSION --> MAIN["Protected main"] + MAIN --> NATIVE["Full native platform matrix"] + MAIN --> E2E["Multi-profile E2E"] + MAIN --> RELEASE["Release delivery"] +``` + +## Running focused gates locally + +Run the contracts relevant to the change: ```sh make build +make policy make interface-integrity make authoritative-interface-integrity BASE_REF= make schema-compatibility BASE_REF= make skill-command-integrity make cli-smoke -# Run on the corresponding native runner with its generated profile: -make coverage-gate-platform BASE_REF= PROFILE= +make mock-mcp-smoke +go test -v -count=1 ./pkg/editiontest/... ``` -`make coverage-gate` is the enforcement step, not a profile generator. It -expects the candidate, policy, shortcut, and merge-base profiles -(`coverage.txt`, `coverage-policy.txt`, `coverage-shortcut.txt`, and -`coverage-base.txt`) produced by the parallel CI profile jobs. A clean local -checkout can reproduce the Linux/overall CI gate sequentially with: +For an exact CHANGELOG-only branch: ```sh base_ref=$(git merge-base HEAD origin/main) -root=$(pwd) -base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX") -rmdir "$base_worktree" -cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; } -trap cleanup EXIT HUP INT TERM - -go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic \ - ./ ./cmd/... ./internal/... ./skills/... -go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \ - ./pkg/... ./scripts/policy/... -go test -count=1 \ - -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \ - -coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \ - -coverprofile=coverage-shortcut.txt \ - -covermode=atomic \ - ./internal/app ./internal/helpers ./internal/shortcut/... -git worktree add --detach "$base_worktree" "$base_ref" -( - cd "$base_worktree" - go test -count=1 -p 1 \ - -coverprofile="$root/coverage-base.txt" -covermode=atomic \ - ./ ./cmd/... ./internal/... ./skills/... -) -COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \ - make coverage-gate BASE_REF="$base_ref" +./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD ``` -The native-platform target likewise expects `PROFILE` to have already been -generated on that operating system. CI owns those generation steps; copying -only either enforcement command into a clean checkout is intentionally an -incomplete invocation. +`make coverage-gate` is an enforcement step, not a profile generator. CI +generates the candidate, supporting, merge-base, and (when risk-selected) +native profiles before the aggregate `Coverage` context evaluates them. The +aggregate and native gates require 100% coverage for changed executable Go +statements. Overall coverage remains an unrounded, zero-tolerance merge-base +non-regression check. Candidate and baseline profiles are evaluated by the +same block-deduplicating checker; supporting policy and shortcut profiles +contribute to changed-code coverage only. The checked-in badge is presentation +only and is never read as a gate input. -CI derives the authoritative Interface snapshots from both the PR merge-base -and the latest reachable stable release tag. The complete Schema snapshot comes -from the PR merge-base, which contains the registry-first Schema introduced on -`main`. The candidate branch cannot bless a breaking change by editing a -fixture. Schema additions are allowed; historical products, tools, parameters, -parameter mappings, positional execution fields, constraints, and safety -semantics remain protected. Positional descriptions are documentation and may -change without breaking compatibility. - -`make update-interface-baseline` still extends the local checked-in Interface -fixture used by `make interface-integrity`. Updates are monotonic: they add new -commands and flags without removing history. - -For an intentional compatibility reset at a major-version boundary, run -`make reset-interface-baseline`. This replaces all CLI compatibility history -with the current command tree and must receive explicit human review. +Compatibility checks derive authoritative Interface snapshots from the PR +merge-base and the latest reachable stable release. The candidate cannot bless +a breaking change by editing a fixture. Schema additions are allowed; +historical products, tools, parameters, mappings, positional execution fields, +constraints, and safety semantics remain protected. ## Required GitHub repository settings -Create a ruleset for `main` that requires pull requests and code-owner review, -then mark these aggregate status checks as required: +The `main` quality ruleset must enable strict required-status-check policy +(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever +`main` advances. It must require these exact contexts and no legacy aliases: -- `CI Gate` -- `Multi Profile E2E` -- `AI Behavior Check` +- `Lint` +- `Test` +- `Coverage` +- `Policy` +- `Edition` +- `Interface Integrity` +- `AI Behavior` +- `CLI Smoke` +- `Mock MCP` -`CI Gate` fails closed unless every first-layer CI job succeeds, including -lint, tests, native Linux/Windows/macOS coverage, policy, -Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate -check keeps repository rules stable when an internal job is renamed or split. - -The `ai-generated` label must be applied by the PR-creation automation or by a -maintainer; GitHub cannot infer reliably whether a human-authored PR contains -AI-generated code. +Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission +alias. Update ruleset contexts only after the new names have appeared on the +protected branch, so a rename cannot silently remove enforcement or leave an +unproducible required context. diff --git a/docs/releasing.md b/docs/releasing.md index 5a4b4125..44934361 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,10 +1,54 @@ # 发布手册(预发 / 正式) -发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。 +发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。 -发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。 +发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。 -## 日常只用一个入口 +## 推荐入口:GitHub 云端发布 + +任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布: + +1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。 +2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。 +3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。 +4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。 + +`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。 + +OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。 + +## 自动版本规则 + +- beta:如果存在尚未封正式版的最高版本线,自动取 `beta.N+1`;否则从最新已分配正式版按所选 patch/minor/major 开新线并取 `beta.1`。 +- stable:先锁定最高开放版本线上的最新已分配 beta,再要求它已成功交付且未撤回;不会跳过失败/撤回的最新 beta 去选择更早版本。正式版 core 与该 beta 完全相同。 +- `vX.Y.Z`、`vX.Y.Z-beta.N` 一经分配就永久占用。撤回时创建 `withdrawn/v...` 墓碑,原编号永不复用。 +- 例如撤回 `v1.0.53-beta.5` 后,下一 beta 是 `v1.0.53-beta.6`;撤回正式版 `v1.0.53` 后,下一 patch 修复线是 `v1.0.54-beta.1`,验证后再发布 `v1.0.54`。 +- 如果最新 beta 已撤回,禁止直接用更早 beta 晋级正式版;必须先构建下一个 beta。 + +## 全平台撤回与回滚 + +已公开版本出现问题时,在 GitHub Actions 运行 `Withdraw release`,分支必须选择当前默认分支 `main`,并填写: + +- `version`:精确版本,例如 `v1.0.53` 或 `v1.0.53-beta.5`。 +- `reason`:8–300 字符的单行公开原因。 +- `confirmation`:精确输入 `WITHDRAW `,例如 `WITHDRAW v1.0.53`。 + +该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行: + +1. 先创建永久 annotated tag `withdrawn/`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。 +2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。 +3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;只有目标 tag 封存了 `OSS-Mirror: enabled` 时,OSS 才会先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。 +4. npm 以及目标 tag 启用或发布时配置的镜像渠道均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。 + +GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。 + +撤回前必须存在同一渠道中更早、完整交付且未撤回的安全版本;若目标是该渠道第一个版本、没有安全候选,workflow 会在创建墓碑或修改任何渠道前 fail closed,需要先决定明确的替代策略。CLI 本地 rollback 也只有在本机仍保留上一次升级备份时可用。 + +撤回以“精确版本”为单位,不会因为正式版曾由某个 beta 晋级就隐式级联修改另一个渠道。若同一缺陷同时存在于正式版及其 beta,应先撤回正式版,再撤回对应 beta,并分别使用各自的精确确认串;每次都只会把该渠道回退到自己的安全候选。 + +撤回正式版 `v1.0.53` 后,`v1.0.53` 仍被墓碑视为已分配。下一次 patch 发布从 `v1.0.54-beta.1` 开始,验证后晋级 `v1.0.54`。撤回 `v1.0.53-beta.5` 后,同一开放版本线继续为 `v1.0.53-beta.6`;不会退回或复用 `beta.5`。 + +## 兼容入口:本地发布 安装发布 Skill 后直接运行: @@ -25,11 +69,11 @@ dws-release config --remote origin ```text main 上的候选代码 + beta CHANGELOG → vX.Y.Z-beta.N(预发验证) - → 只允许补正式 CHANGELOG,源码不得再变化 - → vX.Y.Z(正式发布) + → 补正式 CHANGELOG;允许继续通过 PR 合入新 commit + → vX.Y.Z(正式发布,封板提交必须包含该 beta 提交) ``` -正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。 +云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线要求该 beta 已成功交付、未撤回,且 beta 提交必须位于正式发布封板提交的历史中——不能跳过 beta 直接发正式版,但允许在 beta 之后把经过 review 合入 `main` 的 commit 一起发布。 ## 预发发布 @@ -45,7 +89,7 @@ dws-release v1.2.3-beta.1 dws-release v1.2.3-beta.1 ``` -预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查 `CI Gate` 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`: +预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`: ```bash dws-release v1.2.3-beta.1 --publish @@ -68,7 +112,7 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish ``` -`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。 +本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。 ## CHANGELOG 契约 @@ -86,49 +130,66 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish ## CI/CD 保证 -- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。 -- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。 +- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。 +- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。 - 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。 - GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。 - 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。 -- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。 -- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。 -- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。 +- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。 +- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。 +- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。 npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。 -OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。 +已启用的 OSS 或 Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发 +Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel +repair 会精确绑定失败 tag run 的最新 attempt,且 OSS repair 要求 tag 的 sealed policy 为 `enabled`;contract、构建、Developer ID 签名、 +immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败, +随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step; +Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功, +不会把仍未修复的 OSS 标成成功。该证据不能用于 beta → stable 或 +stable baseline,后两者仍要求整条 Release 成功或受保护 recovery 成功。不要重跑旧 +attempt 的单个 failed job,以免在 attempts 之间拼接交付证据。独立 Gitee release +workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。 ## 既有 tag 的紧急恢复 -tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行: +云端封板或本地 tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行: ```bash dws-release recover v1.2.3-beta.1 ``` -命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run ` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足: +命令会自动解析 annotated tag object、peeled commit,以及 tag 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run ` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足: -- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。 -- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。 +- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。 +- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。 - `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。 -- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。 +- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。 - 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。 成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。 -OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。 +云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。 -Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。 +OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。 + +Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。 ## 平台治理前置 仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则: -- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。 +- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。 - 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。 +- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。 - 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。 +- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。 +- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。 +- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。 - 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。 - 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。 +- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。 +- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。 -immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 +immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 diff --git a/docs/shortcut-comparison.html b/docs/shortcut-comparison.html deleted file mode 100644 index c744f083..00000000 --- a/docs/shortcut-comparison.html +++ /dev/null @@ -1,22351 +0,0 @@ - - -DWS Shortcut 三方对照(dws vs lark-cli vs 原生 MCP) - -

DWS Shortcut 三方指令对照

-
每个 shortcut:dws +命令 vs lark-cli vs dws 原生 MCP 组合 · 源码自动提取
-
-
366
shortcut 总数
-
68
复合 / 智能型
-
298
原子型
-
16
服务
-
50
lark 同名可对齐
-
53
钉钉特有(无 lark)
-
39
lark 平台特有
-
366
逐条实测覆盖
-
204
真实只读后端测试
-
162
真实写入后端测试
-
-
-
读表说明:① dws shortcut = 新增的精选命令(<xxx> 为参数占位)。 -② 原生 MCP / 编排说明:原子型展示等价裸命令;复合型说明其分页、消歧、多步调用、聚合或回滚职责。 -③ lark-cli = 飞书对应命令:绿色=同名可对齐;黄色=lark 有该服务但命名/能力不同;灰色=钉钉特有服务,lark 无对应。因两边 API 不同,仅同名项做逐一对齐。 -④ 复合/智能型 = 不只是参数改名,内部包含多次调用、跨服务 ID 解析、分页、消歧、聚合、投影、批处理或失败回滚。 -⑤ lark 平台特有 = 飞书有独立产品对象或事件模型,但当前 DWS / 钉钉 MCP 没有等价资源,不能通过简单封装实现。 -⑥ 实际测试情况 = 每条 dws shortcut 都通过真实 cobra 命令树执行;fake MCP 拦截所有后端调用,因此不发网络、不写线上数据。绿色=已装配到 MCP 调用;黄色=synthetic 参数被命令自身校验正确拦截;红色=失败。每行都可展开查看本次测试的 CLI 输入、stdout、stderr/error。 -⑦ 真实后端测试 = 用真实 dws 二进制、真实登录态、真实后端执行(无 --mock、无 --dry-run),逐条记录真实输入与真实 stdout/stderr。read 已全量覆盖;write / high-risk-write 按临时测试资源和测试人员分批推进,未覆盖项会继续补测。 -⑧ 逐项失败 review = shortcut-error-review.html 已把每个真实失败逐条标出“要改哪里 / 具体改法 / 验证方式”。
- -

逐条实际测试矩阵 · 366 / 366 覆盖

-

本页每一条 dws shortcut 都有测试记录。测试由 scripts/gen_shortcut_test_matrix.go 生成:逐条喂入合成参数,实际执行 cobra 命令树,所有 MCP 调用由 fake caller 拦截并记录 product/tool/args,同时捕获每条命令的 CLI 输入、stdout、stderr/error,确保零网络、零副作用。结果:323 条已装配到真实 MCP tool,43 条被自身校验按预期拦截,失败 0 条,tool ground truth 校验失败 0 条。

- - - - - -
测试类型数量含义副作用边界
已实测装配323命令实际执行到 MCP 调用组装路径,并记录 product/tool/argsfake MCP 拦截,不访问后端,不写数据
实测校验拦截43命令实际执行到自身校验链路,synthetic 参数被合法拒绝校验前停止,无 MCP 调用
失败0panic、无调用无错误、或 tool ground truth 未通过必须为 0 才允许生成报告
-

真实后端只读测试 · 204 / 204 read 覆盖

-

这一层不使用 mock,也不使用 dry-run:用临时构建的真实 dws 二进制和当前 profile 登录态执行 read shortcut,并捕获真实 stdout/stderr。结果:成功 162 条,真实后端/参数/鉴权错误 41 条,超时 0 条。大量错误并不代表命令未执行,而是后端真实返回了 not_authenticated、参数校验、资源不存在或权限/业务错误;这些原始输出已逐条写在主表展开项中。

- - - - - -
真实测试类型数量含义副作用边界
真实后端成功162命令真实访问后端并 exit 0仅 read 命令
真实后端返回错误41命令真实访问后端或真实执行本地校验后返回错误;stdout/stderr 已逐条记录仅 read 命令
真实后端超时0超过测试超时时间仅 read 命令
-

只读真实失败归因

- - - - - - - - -
失败归因数量说明
输入或业务校验失败16命令真实进入校验;可能是安全负向输入触发,也可能需要更完整 fixture。
后端 / MCP 内部错误9服务端/MCP 返回内部错误,报告保留原始输出供后端排查。
缺真实资源 fixture7资源、消息、群、文档、单据等真实对象不存在。
权限 / 鉴权不足6账号、应用 scope 或资源权限不足;需要授权/换账号/配置应用权限。
缺妙记/听记真实 fixture3妙记/听记命令依赖当前账号可见的真实会议产物。
人工授权后执行1无安全负向目标或高风险动作,需人工逐项确认。
-

真实后端写入测试 · 162 / 162 write 覆盖中

-

这一层同样不使用 mock,也不使用 dry-run:用真实 dws 二进制、当前登录态、临时测试资源以及测试人员(冬翔、克谨、怒龙)分批执行 write / high-risk-write shortcut。当前已记录 162 条:成功 48 条,真实后端/参数/业务错误 114 条,超时 0 条,待执行 0 条。写入类的失败同样保留真实 stdout/stderr,便于定位是 shortcut 参数投影问题、后端业务约束还是权限限制。

- - - - - -
真实测试类型数量含义副作用边界
真实后端成功48命令真实访问后端并 exit 0临时资源 / 测试人员
真实后端返回错误114命令真实访问后端或真实执行本地校验后返回错误;stdout/stderr 已逐条记录临时资源 / 测试人员
真实后端超时0超过测试超时时间需单独复测
-

写入真实失败归因

- - - - - - - -
失败归因数量说明
缺 AI 表格真实 fixture31AI 表格命令依赖真实 Base/Table/View/Record 等资源。
后端 / MCP 内部错误24服务端/MCP 返回内部错误,报告保留原始输出供后端排查。
缺真实资源 fixture23资源、消息、群、文档、单据等真实对象不存在。
输入或业务校验失败20命令真实进入校验;可能是安全负向输入触发,也可能需要更完整 fixture。
权限 / 鉴权不足16账号、应用 scope 或资源权限不足;需要授权/换账号/配置应用权限。
-

非复合 / 智能型 shortcut 为什么也要写? · 298 个

-

原子型 shortcut 不是在宣称“新增了底层能力”,而是在 MCP tool 之上提供一层稳定、可发现、可测试的用户入口。它的价值边界要讲清楚:有增量就保留,纯重复就删除。

- - - - - - - - -
必要性没有原子型 shortcut 时保留原子型 shortcut 的价值
补齐命令面空白有些 MCP tool 没有对应的 dws <svc> <verb> helper,只能走裸 dws mcp ... --json把真实可用的底层能力挂到产品命令树里,用户和 Agent 不必手写 JSON
参数收敛调用者要记服务端字段名、嵌套 JSON、枚举和必填关系收敛成命名 flag、默认值和校验,降低误填与 prompt 猜参概率
Intent / Help / 可发现性MCP schema 偏接口视角,Agent 需要先理解 tool 再判断何时使用每条 shortcut 都带任务化描述,可被 --help、dws shortcut list 和 Agent 规划直接利用
统一安全与输出入口读写风险、dry-run、format / jq / fields 等能力分散到调用者侧复用 shortcut 框架的 risk 分级、dry-run 通道和统一输出契约
稳定投影底层响应 envelope 冗长且字段不稳定,常把噪声暴露给用户对一部分读类命令保留干净投影,只输出用户真正要看的字段
对齐与治理无法判断与 lark-cli、helper、MCP tool 的覆盖差异,也难以做后续去重形成可枚举的命令面,便于生成报告、做 GSB 对比和持续 prune
-
保留标准:原子型 shortcut 必须至少满足“补 helper 空白”或“提供投影/校验/Intent 等体验增量”。本轮已删除 213 条仅换名字、无投影增量、且 helper 已覆盖的纯重复 wrapper;所以这里的原子型不是为了凑数量,而是为了把仍有价值的底层能力变成稳定入口。
-

复合 / 智能型 shortcut · 68 个

-

这组命令面向完整用户意图,例如“按姓名发消息”“找共同空闲时间”“找到最新妙记并取逐字稿”。其中一部分是真正的多 MCP / 跨服务编排,另一部分是单服务内的分页、过滤、批处理和稳定输出;两者都不是普通 API 别名。

-
-

calendar · 15

    -
  • dws calendar +book创建日程,并可按姓名邀请参会人(自动解析 userId,失败自动回滚删除日程)
  • -
  • dws calendar +cancel-event取消(删除)一个已有日程(删除前先确认它真实存在)
  • -
  • dws calendar +conflicts检测我某天日程的时间冲突(重叠/双重预订,默认今天)
  • -
  • dws calendar +find-room查询指定时间段内所有可用的会议室
  • -
  • dws calendar +free按姓名查询某人在指定时间段内的忙闲状态(自动解析 userId)
  • -
  • dws calendar +free-slots找我某天工作时段内的空闲时间段(默认今天 09:00-18:00)
  • -
  • dws calendar +invite按姓名把参会人加入已有日程(自动解析 userId 后批量添加)
  • -
  • dws calendar +my-free查我自己在某时间段的忙闲(默认今天,无需输入姓名)
  • -
  • dws calendar +next-event查看接下来最近的一个日程(默认扫描未来 7 天)
  • -
  • dws calendar +reschedule改一个已有日程的时间(只动开始/结束时间,其他字段不变)
  • -
  • dws calendar +respond-event接受 / 拒绝 / 暂定回复一个日程邀请(作为参会人设置自己的响应状态)
  • -
  • dws calendar +suggest-time按姓名解析多位参与者,推荐大家都有空的可开会时间段(自动解析 userId)
  • -
  • dws calendar +today列出我今天的日程(自动计算今天的起止时间,无需手动填时间范围)
  • -
  • dws calendar +tomorrow列出我明天的日程(自动计算明天的起止时间,无需手动填时间范围)
  • -
  • dws calendar +week列出我本周的日程(自动按周一为周首计算本周起止时间,无需手动填时间范围)
  • -
-

chat · 10

    -
  • dws chat +at-me查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)
  • -
  • dws chat +broadcast按姓名逐一给多个人群发同一条单聊消息(自动解析 userId、逐个发送)
  • -
  • dws chat +chat-messages拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间
  • -
  • dws chat +dm按姓名直接给某人发单聊消息(自动解析 userId)
  • -
  • dws chat +group-members按群名列出群成员(自动搜群解析 openConversationId)
  • -
  • dws chat +my-groups列出我加入的群,可按类型过滤并投影关键字段
  • -
  • dws chat +search-msg在群内按关键词搜消息(默认近 7 天,投影发送人/时间/内容/messageId)
  • -
  • dws chat +send-to-group按群名直接给群发消息(自动搜群解析 openConversationId)
  • -
  • dws chat +thread-replies拉取某条话题消息的全部回复并投影出发言人/文本/时间
  • -
  • dws chat +unread-chats列出我有未读消息的会话(投影会话名/未读数/会话ID)
  • -
-

todo · 8

    -
  • dws todo +assign按姓名给某人创建并指派一条待办(自动解析 userId)
  • -
  • dws todo +assign-multi把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)
  • -
  • dws todo +created-todos列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)
  • -
  • dws todo +due-today列出我今天到期的待办
  • -
  • dws todo +overdue列出我已过期未完成的待办
  • -
  • dws todo +related-tasks一次性列出与我相关的全部待办(我作为创建人/执行人/参与人三种角色的并集,按 taskId 去重)
  • -
  • dws todo +remind给自己创建一条带截止/提醒时间的待办
  • -
  • dws todo +todo-done按标题关键词把我的某条待办标记完成(自动定位 taskId)
  • -
-

contact · 7

    -
  • dws contact +by-mobile按手机号查询某人的完整资料(自动解析 userId 后取详情)
  • -
  • dws contact +dept-members按部门名列出部门成员(自动解析 deptId)
  • -
  • dws contact +lookup按姓名查询某人的完整资料(自动解析 userId 后取详情)
  • -
  • dws contact +me查看我自己的通讯录资料(姓名/userId/手机/部门/组织,干净投影)
  • -
  • dws contact +org按姓名查某人所在部门的详情(自动解析 userId 与 deptId)
  • -
  • dws contact +resolve-dept按名称搜索部门并解析出唯一 deptId(只读)
  • -
  • dws contact +team按姓名列出某人所在部门的成员(自动解析 userId 与 deptId)
  • -
-

minutes · 6

    -
  • dws minutes +action-items取我最新一条妙记里的待办事项
  • -
  • dws minutes +detail一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)
  • -
  • dws minutes +latest-minutes取我最新的一条妙记(听记)详情
  • -
  • dws minutes +minutes-search按关键词搜索我的妙记并投影列表
  • -
  • dws minutes +replace-batch对一条妙记(听记)批量执行多组文字替换(原文=>替换)
  • -
  • dws minutes +transcript取我最新一条妙记(听记)的逐字稿(语音转写原文)
  • -
-

aitable · 5

    -
  • dws aitable +find-record在指定多维表里按关键词查记录(只读)
  • -
  • dws aitable +list-tables列出某个多维表(base)里的所有数据表(只读,投影 tableId/tableName)
  • -
  • dws aitable +record-share-links批量(可 >20 条)获取多维表记录分享链接:去重+分片+合并
  • -
  • dws aitable +resolve-base按名称搜索多维表 Base 并解析出唯一 baseId(只读)
  • -
  • dws aitable +resolve-table在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)
  • -
-

mail · 4

    -
  • dws mail +find-mail-user按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)
  • -
  • dws mail +recent-mail列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)
  • -
  • dws mail +search-mail按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId)
  • -
  • dws mail +unread-mail列出未读邮件并投影列表(主题/发件人/时间/messageId)
  • -
-

oa · 4

    -
  • dws oa +approve-by按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)
  • -
  • dws oa +done-approvals只读列出我已处理过的审批任务(审批历史)并投影为可读列表
  • -
  • dws oa +my-initiated列出我发起(提交)的审批单据
  • -
  • dws oa +pending只读列出待我审批的审批任务并投影为可读列表(只看不批)
  • -
-

doc · 3

    -
  • dws doc +doc-append在文档末尾追加一段文本(安全追加,不改动原有内容)
  • -
  • dws doc +find-doc按关键词搜索云文档并投影关键字段(只读)
  • -
  • dws doc +share-doc按姓名把文档链接私信发给某人(自动解析 userId)
  • -
-

attendance · 2

    -
  • dws attendance +my-attendance查我今天的考勤打卡记录(打卡流水,自动解析当前用户)
  • -
  • dws attendance +this-month查我本月的考勤打卡记录(打卡流水,自动解析当前用户)
  • -
-

wiki · 2

    -
  • dws wiki +resolve-space按名称搜索知识空间并解析出唯一 spaceId(只读)
  • -
  • dws wiki +wiki-new-doc在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)
  • -
-

drive · 1

    -
  • dws drive +find-file按名称关键词搜索钉盘文件并投影关键字段(只读)
  • -
-

report · 1

    -
  • dws report +report-latest取我最新提交的一篇日志/汇报详情(钉钉原生)
  • -
-
-

为什么不只提供 API 形态?

-

API/MCP 形态以服务端资源和接口为中心;shortcut 以用户任务为中心。单独封装的价值不是少写几个字符,而是把稳定的客户端语义、保护措施和多步流程变成可测试契约。

- - - - - - - - - -
维度直接 API / MCP单独 shortcut 封装
输入语义要求调用者理解服务端字段名、类型、枚举和 JSON 结构提供面向任务的 flags、默认值、枚举、时间解析和跨字段校验
ID 与上下文通常先查 userId、deptId、eventId、baseId,再复制到下一次调用可按姓名/标题解析、唯一性消歧,并自动串联后续操作
多步一致性调用者自行处理部分成功、重试、回滚与中间结果集中实现 fan-out、分页、去重、partial failure 和必要回滚
安全写接口裸露,dry-run 和确认逻辑由每个调用者重复实现统一 risk 分级、确认提示、dry-run 计划和高风险保护
输出契约响应 envelope 和字段随底层工具而异,常含大量噪声稳定投影关键字段,并统一支持 format / jq / fields
Agent 可发现性模型需先理解 API schema,再自己规划完整调用链Intent、示例和窄参数面直接表达“什么时候用、会发生什么”
演进隔离上游参数或响应变化会扩散到所有脚本和 Agent prompt在一个经过测试的适配层吸收兼容变化
-
它不是 API 的替代品。 对一次性、低频、需要完整底层字段或刚上线尚未沉淀的能力,仍应直接调用 API/MCP。只有当封装提供明确增量——复合编排、校验、安全、稳定投影或高频意图——才值得保留;纯粹改名且没有增量的 wrapper 应删除或不新增。
-

lark 平台特有 shortcut · 39 个

-

这批不是“DWS 少写了几个 shortcut”,而是飞书平台有对应的一等产品对象或事件模型,当前钉钉 MCP/DWS 没有同语义承接点。后续只有在钉钉开放对应 API、或 DWS 接入等价 MCP tool 后,才适合再做 shortcut。

-
-

lark event · 1

-
飞书 event shortcut 面向 lark-cli 的事件订阅模型;DWS 的钉钉事件/机器人连接模型不同,目前没有沉淀成同参数、同语义的 shortcut。
-
    -
  • lark-cli event +subscribe
  • -
-

lark markdown · 5

-
lark-cli 的 markdown shortcut 绑定飞书云文档/云盘里的 Markdown 工作流;当前 DWS 没有等价的钉钉 Markdown 云文档资源与 patch/diff/overwrite API。
-
    -
  • lark-cli markdown +create
  • -
  • lark-cli markdown +diff
  • -
  • lark-cli markdown +fetch
  • -
  • lark-cli markdown +overwrite
  • -
  • lark-cli markdown +patch
  • -
-

lark note · 2

-
飞书 Note 是会议纪要直查对象,可按 note_id 取详情和逐字稿;DWS 的 minutes 能力属于钉钉妙记/听记,资源 ID、权限和数据模型不同,不能保证等价。
-
    -
  • lark-cli note +detail
  • -
  • lark-cli note +transcript
  • -
-

lark okr · 13

-
飞书 OKR 是独立目标管理产品;当前 DWS / 钉钉 MCP 没有等价的 OKR 周期、目标、指标和进展对象,不能只靠 wrapper 复刻。
-
    -
  • lark-cli okr +batch-create
  • -
  • lark-cli okr +cycle-detail
  • -
  • lark-cli okr +cycle-list
  • -
  • lark-cli okr +indicator-update
  • -
  • lark-cli okr +patch
  • -
  • lark-cli okr +progress-create
  • -
  • lark-cli okr +progress-delete
  • -
  • lark-cli okr +progress-get
  • -
  • lark-cli okr +progress-list
  • -
  • lark-cli okr +progress-update
  • -
  • lark-cli okr +reorder
  • -
  • lark-cli okr +upload-image
  • -
  • lark-cli okr +weight
  • -
-

lark slides · 6

-
飞书 Slides 是原生演示文稿对象,支持页面替换、截图、XML 和媒体上传;当前 DWS 没有钉钉幻灯片对象模型与页面级 MCP 能力。
-
    -
  • lark-cli slides +create
  • -
  • lark-cli slides +media-upload
  • -
  • lark-cli slides +replace-pages
  • -
  • lark-cli slides +replace-slide
  • -
  • lark-cli slides +screenshot
  • -
  • lark-cli slides +xml-get
  • -
-

lark vc · 9

-
飞书 VC 暴露会议加入/离开、会中事件、消息、录制等对象;当前 DWS 没有一套干净的钉钉会议 MCP tool 可等价承接,已有事件能力也不是同一套会议生命周期语义。
-
    -
  • lark-cli vc +detail
  • -
  • lark-cli vc +meeting-events
  • -
  • lark-cli vc +meeting-join
  • -
  • lark-cli vc +meeting-leave
  • -
  • lark-cli vc +meeting-list-active
  • -
  • lark-cli vc +meeting-message-send
  • -
  • lark-cli vc +notes
  • -
  • lark-cli vc +recording
  • -
  • lark-cli vc +search
  • -
-

lark whiteboard · 3

-
飞书 Whiteboard 有独立画板节点、查询和结构化更新能力;当前 DWS 没有钉钉画板节点 schema 或对应 MCP tool。
-
    -
  • lark-cli whiteboard +query
  • -
  • lark-cli whiteboard +update
  • -
  • lark-cli whiteboard +whiteboard-update
  • -
-
-

chat · 89 个 · ↔ lark im

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws chat +at-me --days <days>read复合/智能型
查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/search_at_me_message
输入 / CLI 输出
输入
dws chat +at-me --days 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cursor": "0",
-    "endTime": 1784105092577,
-    "limit": 50,
-    "startTime": 1784018692577
-  },
-  "product": "chat",
-  "tool": "search_at_me_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cursor": "0",
-    "endTime": 1784105092577,
-    "limit": 50,
-    "startTime": 1784018692577
-  },
-  "product": "chat",
-  "tool": "search_at_me_message"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2787ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +at-me --days 7 --yes --format json
真实 stdout
{
-  "messages": [
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@梓寻 @崇幻(主用钉) @陈叙 @念晨 明天下午给孙总的蓝图汇报,最后一part是对本次AI营销项目的建议和求助。大家可以把每个模块目前遇到的问题和困难(例如:需要客户提供的资源、需求落地的阻力等),单独反馈给我。",
-      "time": "2026-07-15 14:29:53"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 这个知识蓝图好了么?客户想先看一下材料",
-      "time": "2026-07-15 13:41:25"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 @杨淑平 \n1、**生产环境:补充一下是否需要域名**[图片消息](mediaId=@lQLPJxN0PcmAzMfNBfDNCuKwMJ8nHWzNI30KKoLRQapEAQ)**2、测试环境:**\n(1)需要几台服务器,配置保持跟生产一样,还是改一下\n(2)测试环境是否需要域名,(测试环境就写是否需要好了,可以让客户随机分配下)[图片消息](mediaId=@lQLPJwPJXqYLLMfNBf7NCqCw5iUu-ba7L7IKKoLRQapEAA)",
-      "time": "2026-07-15 10:19:17"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "栩铭-主账号",
-      "text": "@徐洪鑫(崇幻(主用钉)) @董鑫阳(念晨) @陈绪(陈叙) @杨淑平 @梓寻 \n需要申请 测试服务器环境的同学,在https://alidocs.dingtalk.com/i/nodes/PwkYGxZV3ZEBnPvwI92oDm9rWAgozOKL?utm_scene=team_space&iframeQuery=sheet_range%3Dkgqie6hm_0_11_9_3  “测试环境”Sheet页登记下\n需要域名的,填写在新增的“域名”栏目",
-      "time": "2026-07-15 09:50:16"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@梓寻 @陈叙 @崇幻(主用钉) @念晨  各业务场景的蓝图素材还请大家统一补充到钉钉文档中 https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPzYvRArmHv23ye4kWzN67Mw4",
-      "time": "2026-07-14 22:44:59"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@崇幻(主用钉) @陈叙 @念晨 @杨淑平 今日进展和明日计划记得发一下哈,\n\n另外再看一下情报助手、活动助手、解决方案助手、专家Agent什么时候可以让中控一线人员在自己的电脑上运行(最小MVP版本),要给下时间",
-      "time": "2026-07-14 21:35:38"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 选李龙就是@粤腾 批啦[憨笑]",
-      "time": "2026-07-14 20:33:28"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@崇幻(主用钉) @念晨 @梓寻 @陈叙 如果大家明天上午方便,最好能来中控现场,蓝图规划和场景demo录制两个事现场对齐高效输出。[抱拳]",
-      "time": "2026-07-14 19:13:20"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@崇幻(主用钉) @陈叙 @梓寻 @念晨 明天要给新的分管It和营销业务副总裁孙总汇报整体项目蓝图规划,辛苦大家今天内把每个模块用户故事动线(产品原型图,demo界面,场景等)串起来的相关素材放到知识库。https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYrlp4LN6IBEylmR48akx1Z5N?corpId=ding5bb1eba46e6a17d94ac5d6980864d335&utm_medium=im_card&iframeQuery=utm_medium%3Dim_card%26utm_source%3Dim&utm_scene=team_space&utm_source=im",
-      "time": "2026-07-14 16:28:06"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 @陈叙 会议室在 E307",
-      "time": "2026-07-14 13:12:04"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "崇幻这边可以现场演示,@念晨 @陈叙 你俩下午赶的过来么?",
-      "time": "2026-07-14 11:13:17"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 @粤腾 @梓寻 @陈叙 @崇幻(主用钉) 崔总突然下午要看项目整体进度,需要汇报每个子项目的情况,大概2点半这样。",
-      "time": "2026-07-14 11:11:28"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 明日计划给你加个知识蓝图输出中,然后约客户周三对焦哈",
-      "time": "2026-07-13 23:14:03"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@陈叙 @念晨 @杨淑平 今日进展和明天计划记得同步下[送花花]",
-      "time": "2026-07-13 21:40:08"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "还差一个情报助理和 知识库与中控制标Agent打通。  @杨淑平 情报助理看下周三能做到什么程度。 @念晨 知识库与中控制标Agent打通 需要在7 18 的demo演示中打通么。",
-      "time": "2026-07-13 16:43:23"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "璟琦(主用钉)",
-      "text": "@董鑫阳(念晨) https://acl.alibaba-inc.com/permission/datapermission/applyDetail.htm?spm=21540d8c.6248353e.0.0.6ae2264c9zDMFB&flowId=3cd438c0-2cee-4eac-a763-4625dbf2b51d",
-      "time": "2026-07-15 10:54:23"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "九韶",
-      "text": "@董鑫阳(念晨) 给你留了一个目标, 你抓紧更新,进去。 我给大哥先发一般",
-      "time": "2026-07-14 16:15:16"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "璟琦(主用钉)",
-      "text": "@董鑫阳(念晨) 你补一下 中控 及  专家agent 的部分",
-      "time": "2026-07-14 15:48:29"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "璟琦(主用钉)",
-      "text": "@董鑫阳(念晨) cli实现的时候这里面的能力最好都支持",
-      "time": "2026-07-14 10:49:39"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "筱淘",
-      "text": "@董鑫阳(念晨) 让客户再等等 明天见客户",
-      "time": "2026-07-15 09:48:49"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 没事,下次星爷的美人鱼2要在2028年上映了,到时候我们再一起翘班去",
-      "time": "2026-07-15 09:41:06"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@卢翮(龙衔) @董鑫阳(念晨) 去么去么",
-      "time": "2026-07-15 09:22:09"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) @蒋志刚(穆伦) @卢翮(龙衔) 去么,去么",
-      "time": "2026-07-15 09:16:18"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 哪个 HR 问你",
-      "time": "2026-07-15 09:10:25"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "穆伦",
-      "text": "@Dennis(念晨) 为啥这么问呢?你们那边没有好的位置?",
-      "time": "2026-07-15 09:05:14"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "筱淘",
-      "text": "@董鑫阳(念晨) 还等什么 来吧",
-      "time": "2026-07-15 09:00:21"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 害怕",
-      "time": "2026-07-14 22:01:18"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "穆伦",
-      "text": "@Dennis(念晨) 展开说说",
-      "time": "2026-07-14 16:39:26"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "龙衔",
-      "text": "@董鑫阳(念晨) 聊啥",
-      "time": "2026-07-14 16:36:03"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "穆伦",
-      "text": "@Dennis(念晨) 什么评价",
-      "time": "2026-07-14 16:33:04"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 我就说旁边有个车神经的滴滴我",
-      "time": "2026-07-14 13:04:30"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 赛银",
-      "time": "2026-07-14 12:51:33"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 刚才出来的车是你的呀",
-      "time": "2026-07-14 12:51:27"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 你最后一条为什么只出现一半",
-      "time": "2026-07-14 10:27:07"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) iPad or Mac?",
-      "time": "2026-07-14 10:23:28"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "龙衔",
-      "text": "@董鑫阳(念晨) 走,请你吃面",
-      "time": "2026-07-13 18:26:51"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "龙衔",
-      "text": "@董鑫阳(念晨) 茶叶忘了给你了 下次吃饭吧 头大 脑子不够了",
-      "time": "2026-07-13 14:14:22"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 下午你来",
-      "time": "2026-07-13 11:37:02"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 你是开个新卡,还是试试筱淘那个100多的",
-      "time": "2026-07-13 11:33:47"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨)",
-      "time": "2026-07-13 11:32:43"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 我给你开个卡?",
-      "time": "2026-07-13 11:28:36"
-    },
-    {
-      "conversation": "元智,九韶,璟琦(主用钉),念晨",
-      "sender": "元智",
-      "text": "@董鑫阳(念晨) 给力,scope更大了",
-      "time": "2026-07-14 15:39:19"
-    },
-    {
-      "conversation": "元智,九韶,璟琦(主用钉),念晨",
-      "sender": "九韶",
-      "text": "@董鑫阳(念晨) 汇报过了吗?  怎么样?",
-      "time": "2026-07-14 15:19:43"
-    },
-    {
-      "conversation": "[会议群]销售知识库方案讨论",
-      "sender": "赵瑞(玖霄)",
-      "text": "我来约你时间@念晨",
-      "time": "2026-07-14 12:37:23"
-    },
-    {
-      "conversation": "[会议群]销售知识库方案讨论",
-      "sender": "迦卢",
-      "text": "@赵瑞(玖霄) 你把需求和@念晨 对焦下",
-      "time": "2026-07-14 12:25:15"
-    },
-    {
-      "conversation": "[会议群]销售知识库方案讨论",
-      "sender": "迦卢",
-      "text": "@赵瑞(玖霄) @念晨 是知识库这边的技术架构师,目前还没有产品,但是他来进行和我们共创",
-      "time": "2026-07-13 17:48:48"
-    },
-    {
-      "conversation": "FDE Go",
-      "sender": "冬翔",
-      "text": "@董鑫阳(念晨) 你的Claude还是不能用?",
-      "time": "2026-07-14 10:22:12"
-    },
-    {
-      "conversation": "FDE Go",
-      "sender": "黎常",
-      "text": "@董鑫阳(念晨) 这个确实太傻逼了,放着好好优势不用",
-      "time": "2026-07-14 09:55:49"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "messages": [
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@梓寻 @崇幻(主用钉) @陈叙 @念晨 明天下午给孙总的蓝图汇报,最后一part是对本次AI营销项目的建议和求助。大家可以把每个模块目前遇到的问题和困难(例如:需要客户提供的资源、需求落地的阻力等),单独反馈给我。",
-      "time": "2026-07-15 14:29:53"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 这个知识蓝图好了么?客户想先看一下材料",
-      "time": "2026-07-15 13:41:25"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 @杨淑平 \n1、**生产环境:补充一下是否需要域名**[图片消息](mediaId=@lQLPJxN0PcmAzMfNBfDNCuKwMJ8nHWzNI30KKoLRQapEAQ)**2、测试环境:**\n(1)需要几台服务器,配置保持跟生产一样,还是改一下\n(2)测试环境是否需要域名,(测试环境就写是否需要好了,可以让客户随机分配下)[图片消息](mediaId=@lQLPJwPJXqYLLMfNBf7NCqCw5iUu-ba7L7IKKoLRQapEAA)",
-      "time": "2026-07-15 10:19:17"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "栩铭-主账号",
-      "text": "@徐洪鑫(崇幻(主用钉)) @董鑫阳(念晨) @陈绪(陈叙) @杨淑平 @梓寻 \n需要申请 测试服务器环境的同学,在https://alidocs.dingtalk.com/i/nodes/PwkYGxZV3ZEBnPvwI92oDm9rWAgozOKL?utm_scene=team_space&iframeQuery=sheet_range%3Dkgqie6hm_0_11_9_3  “测试环境”Sheet页登记下\n需要域名的,填写在新增的“域名”栏目",
-      "time": "2026-07-15 09:50:16"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@梓寻 @陈叙 @崇幻(主用钉) @念晨  各业务场景的蓝图素材还请大家统一补充到钉钉文档中 https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPzYvRArmHv23ye4kWzN67Mw4",
-      "time": "2026-07-14 22:44:59"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@崇幻(主用钉) @陈叙 @念晨 @杨淑平 今日进展和明日计划记得发一下哈,\n\n另外再看一下情报助手、活动助手、解决方案助手、专家Agent什么时候可以让中控一线人员在自己的电脑上运行(最小MVP版本),要给下时间",
-      "time": "2026-07-14 21:35:38"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 选李龙就是@粤腾 批啦[憨笑]",
-      "time": "2026-07-14 20:33:28"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@崇幻(主用钉) @念晨 @梓寻 @陈叙 如果大家明天上午方便,最好能来中控现场,蓝图规划和场景demo录制两个事现场对齐高效输出。[抱拳]",
-      "time": "2026-07-14 19:13:20"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "@崇幻(主用钉) @陈叙 @梓寻 @念晨 明天要给新的分管It和营销业务副总裁孙总汇报整体项目蓝图规划,辛苦大家今天内把每个模块用户故事动线(产品原型图,demo界面,场景等)串起来的相关素材放到知识库。https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYrlp4LN6IBEylmR48akx1Z5N?corpId=ding5bb1eba46e6a17d94ac5d6980864d335&utm_medium=im_card&iframeQuery=utm_medium%3Dim_card%26utm_source%3Dim&utm_scene=team_space&utm_source=im",
-      "time": "2026-07-14 16:28:06"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 @陈叙 会议室在 E307",
-      "time": "2026-07-14 13:12:04"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "崇幻这边可以现场演示,@念晨 @陈叙 你俩下午赶的过来么?",
-      "time": "2026-07-14 11:13:17"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 @粤腾 @梓寻 @陈叙 @崇幻(主用钉) 崔总突然下午要看项目整体进度,需要汇报每个子项目的情况,大概2点半这样。",
-      "time": "2026-07-14 11:11:28"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@念晨 明日计划给你加个知识蓝图输出中,然后约客户周三对焦哈",
-      "time": "2026-07-13 23:14:03"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "千启",
-      "text": "@陈叙 @念晨 @杨淑平 今日进展和明天计划记得同步下[送花花]",
-      "time": "2026-07-13 21:40:08"
-    },
-    {
-      "conversation": "【内】中控每日工作进展汇总群",
-      "sender": "粤腾",
-      "text": "还差一个情报助理和 知识库与中控制标Agent打通。  @杨淑平 情报助理看下周三能做到什么程度。 @念晨 知识库与中控制标Agent打通 需要在7 18 的demo演示中打通么。",
-      "time": "2026-07-13 16:43:23"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "璟琦(主用钉)",
-      "text": "@董鑫阳(念晨) https://acl.alibaba-inc.com/permission/datapermission/applyDetail.htm?spm=21540d8c.6248353e.0.0.6ae2264c9zDMFB&flowId=3cd438c0-2cee-4eac-a763-4625dbf2b51d",
-      "time": "2026-07-15 10:54:23"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "九韶",
-      "text": "@董鑫阳(念晨) 给你留了一个目标, 你抓紧更新,进去。 我给大哥先发一般",
-      "time": "2026-07-14 16:15:16"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "璟琦(主用钉)",
-      "text": "@董鑫阳(念晨) 你补一下 中控 及  专家agent 的部分",
-      "time": "2026-07-14 15:48:29"
-    },
-    {
-      "conversation": "算法团队mentor群(内部讨论群)",
-      "sender": "璟琦(主用钉)",
-      "text": "@董鑫阳(念晨) cli实现的时候这里面的能力最好都支持",
-      "time": "2026-07-14 10:49:39"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "筱淘",
-      "text": "@董鑫阳(念晨) 让客户再等等 明天见客户",
-      "time": "2026-07-15 09:48:49"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 没事,下次星爷的美人鱼2要在2028年上映了,到时候我们再一起翘班去",
-      "time": "2026-07-15 09:41:06"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@卢翮(龙衔) @董鑫阳(念晨) 去么去么",
-      "time": "2026-07-15 09:22:09"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) @蒋志刚(穆伦) @卢翮(龙衔) 去么,去么",
-      "time": "2026-07-15 09:16:18"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 哪个 HR 问你",
-      "time": "2026-07-15 09:10:25"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "穆伦",
-      "text": "@Dennis(念晨) 为啥这么问呢?你们那边没有好的位置?",
-      "time": "2026-07-15 09:05:14"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "筱淘",
-      "text": "@董鑫阳(念晨) 还等什么 来吧",
-      "time": "2026-07-15 09:00:21"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 害怕",
-      "time": "2026-07-14 22:01:18"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "穆伦",
-      "text": "@Dennis(念晨) 展开说说",
-      "time": "2026-07-14 16:39:26"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "龙衔",
-      "text": "@董鑫阳(念晨) 聊啥",
-      "time": "2026-07-14 16:36:03"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "穆伦",
-      "text": "@Dennis(念晨) 什么评价",
-      "time": "2026-07-14 16:33:04"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 我就说旁边有个车神经的滴滴我",
-      "time": "2026-07-14 13:04:30"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 赛银",
-      "time": "2026-07-14 12:51:33"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 刚才出来的车是你的呀",
-      "time": "2026-07-14 12:51:27"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 你最后一条为什么只出现一半",
-      "time": "2026-07-14 10:27:07"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) iPad or Mac?",
-      "time": "2026-07-14 10:23:28"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "龙衔",
-      "text": "@董鑫阳(念晨) 走,请你吃面",
-      "time": "2026-07-13 18:26:51"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "龙衔",
-      "text": "@董鑫阳(念晨) 茶叶忘了给你了 下次吃饭吧 头大 脑子不够了",
-      "time": "2026-07-13 14:14:22"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 下午你来",
-      "time": "2026-07-13 11:37:02"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 你是开个新卡,还是试试筱淘那个100多的",
-      "time": "2026-07-13 11:33:47"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨)",
-      "time": "2026-07-13 11:32:43"
-    },
-    {
-      "conversation": "AI全栈",
-      "sender": "怒龙",
-      "text": "@董鑫阳(念晨) 我给你开个卡?",
-      "time": "2026-07-13 11:28:36"
-    },
-    {
-      "conversation": "元智,九韶,璟琦(主用钉),念晨",
-      "sender": "元智",
-      "text": "@董鑫阳(念晨) 给力,scope更大了",
-      "time": "2026-07-14 15:39:19"
-    },
-    {
-      "conversation": "元智,九韶,璟琦(主用钉),念晨",
-      "sender": "九韶",
-      "text": "@董鑫阳(念晨) 汇报过了吗?  怎么样?",
-      "time": "2026-07-14 15:19:43"
-    },
-    {
-      "conversation": "[会议群]销售知识库方案讨论",
-      "sender": "赵瑞(玖霄)",
-      "text": "我来约你时间@念晨",
-      "time": "2026-07-14 12:37:23"
-    },
-    {
-      "conversation": "[会议群]销售知识库方案讨论",
-      "sender": "迦卢",
-      "text": "@赵瑞(玖霄) 你把需求和@念晨 对焦下",
-      "time": "2026-07-14 12:25:15"
-    },
-    {
-      "conversation": "[会议群]销售知识库方案讨论",
-      "sender": "迦卢",
-      "text": "@赵瑞(玖霄) @念晨 是知识库这边的技术架构师,目前还没有产品,但是他来进行和我们共创",
-      "time": "2026-07-13 17:48:48"
-    },
-    {
-      "conversation": "FDE Go",
-      "sender": "冬翔",
-      "text": "@董鑫阳(念晨) 你的Claude还是不能用?",
-      "time": "2026-07-14 10:22:12"
-    },
-    {
-      "conversation": "FDE Go",
-      "sender": "黎常",
-      "text": "@董鑫阳(念晨) 这个确实太傻逼了,放着好好优势不用",
-      "time": "2026-07-14 09:55:49"
-    }
-  ]
-}
dws chat +bot-find --query <query> --limit <limit> --cursor <cursor>read
搜索全部可用机器人(含他人/官方,返回 openDingTalkId 可发单聊)
dws mcp bot <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/search_bots
输入 / CLI 输出
输入
dws chat +bot-find --query x --limit 1 --cursor x --yes
stdout
{
-  "bots": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "bots": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1312ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +bot-find --query '测试' --limit 10 --cursor '' --yes --format json
真实 stdout
{
-  "bots": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "bots": [],
-  "count": 0
-}
dws chat +bot-search --page <page> --size <size> --name <name>read
搜索当前用户自己创建的机器人
dws mcp bot <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/search_my_robots
输入 / CLI 输出
输入
dws chat +bot-search --page 1 --size 1 --name x --yes
stdout
{
-  "count": 0,
-  "robots": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "robots": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1191ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +bot-search --page 1 --size 1 --name 'DWS shortcut 真实测试' --yes --format json
真实 stdout
{
-  "count": 0,
-  "robots": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "robots": []
-}
dws chat +broadcast --to <to> --text <text>write复合/智能型
按姓名逐一给多个人群发同一条单聊消息(自动解析 userId、逐个发送)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;没有任何人收到消息,请检查姓名是否正确
输入 / CLI 输出
输入
dws chat +broadcast --to x --text x --yes
stdout
(空)
stderr / error
没有任何人收到消息,请检查姓名是否正确
最终用于判定的返回
没有任何人收到消息,请检查姓名是否正确
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2388ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +broadcast --to '冬翔,克谨,怒龙' --text 'DWS shortcut 修复复测 20260715:验证 chat +broadcast,请忽略。' --yes --format json
真实 stdout
{
-  "failedCount": 0,
-  "sent": [
-    "夏东翔",
-    "黄渊卓",
-    "刘力"
-  ],
-  "sentCount": 3
-}
真实 stderr / error
(空)
真实返回
{
-  "failedCount": 0,
-  "sent": [
-    "夏东翔",
-    "黄渊卓",
-    "刘力"
-  ],
-  "sentCount": 3
-}
dws chat +category-add-conversation --group <group> --category-ids <category-ids>write
将会话移动到指定的自定义分组中
dws mcp im add_conv_to_categories --json '{"openConversationId":<..>, "categoryIds":<..>}'lark im 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--category-ids: invalid integer "x"
输入 / CLI 输出
输入
dws chat +category-add-conversation --group x --category-ids x --yes
stdout
(空)
stderr / error
--category-ids: invalid integer "x"
最终用于判定的返回
--category-ids: invalid integer "x"
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1213ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test chat +category-add-conversation --group cidDWSREALTESTNOSUCHCONV --category-ids 999999999999 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999117776203e08f9"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999117776203e08f9"
-  }
-}
dws chat +category-create --title <title>write
创建用户自定义会话分组
dws mcp im create_conv_category --json '{"title":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/create_conv_category
输入 / CLI 输出
输入
dws chat +category-create --title x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "title": "x"
-  },
-  "product": "im",
-  "tool": "create_conv_category"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "title": "x"
-  },
-  "product": "im",
-  "tool": "create_conv_category"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=959ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +category-create --title 'DWS测0715' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "categoryId": 1784081668329
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "categoryId": 1784081668329
-  },
-  "success": true
-}
dws chat +category-delete --category-id <category-id>high-risk-write
删除用户自定义会话分组
dws mcp im delete_conv_category --json '{"categoryId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/delete_conv_category
输入 / CLI 输出
输入
dws chat +category-delete --category-id 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "categoryId": 1
-  },
-  "product": "im",
-  "tool": "delete_conv_category"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "categoryId": 1
-  },
-  "product": "im",
-  "tool": "delete_conv_category"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1378ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +category-delete --category-id 1784081668329 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "success": true
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "success": true
-  },
-  "success": true
-}
dws chat +category-listread
获取用户自定义会话分组
dws mcp im <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_user_define_conv_categories
输入 / CLI 输出
输入
dws chat +category-list --yes
stdout
{
-  "categories": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "categories": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1326ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +category-list --yes --format json
真实 stdout
{
-  "categories": [
-    {
-      "categoryId": 1561691365563,
-      "title": "大型摸鱼现场"
-    },
-    {
-      "categoryId": 1761880390389,
-      "title": "悟空"
-    },
-    {
-      "categoryId": 1561692216352,
-      "title": "钉钉"
-    },
-    {
-      "categoryId": 1761880350586,
-      "title": "RELU"
-    },
-    {
-      "categoryId": 1767577825401,
-      "title": "X Project"
-    },
-    {
-      "categoryId": 1770100726133,
-      "title": "A1合作"
-    },
-    {
-      "categoryId": 1764209474291,
-      "title": "CodeAgent"
-    },
-    {
-      "categoryId": 1764818531490,
-      "title": "DeepResearch"
-    },
-    {
-      "categoryId": 1765771015868,
-      "title": "J Project"
-    },
-    {
-      "categoryId": 1761881089886,
-      "title": "客户服务"
-    }
-  ],
-  "count": 10
-}
真实 stderr / error
(空)
真实返回
{
-  "categories": [
-    {
-      "categoryId": 1561691365563,
-      "title": "大型摸鱼现场"
-    },
-    {
-      "categoryId": 1761880390389,
-      "title": "悟空"
-    },
-    {
-      "categoryId": 1561692216352,
-      "title": "钉钉"
-    },
-    {
-      "categoryId": 1761880350586,
-      "title": "RELU"
-    },
-    {
-      "categoryId": 1767577825401,
-      "title": "X Project"
-    },
-    {
-      "categoryId": 1770100726133,
-      "title": "A1合作"
-    },
-    {
-      "categoryId": 1764209474291,
-      "title": "CodeAgent"
-    },
-    {
-      "categoryId": 1764818531490,
-      "title": "DeepResearch"
-    },
-    {
-      "categoryId": 1765771015868,
-      "title": "J Project"
-    },
-    {
-      "categoryId": 1761881089886,
-      "title": "客户服务"
-    }
-  ],
-  "count": 10
-}
dws chat +category-list-conversations --category-id <category-id> --exclude-muted <exclude-muted>read
拉取指定自定义会话分组下的会话
dws mcp im <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_conversations_by_category
输入 / CLI 输出
输入
dws chat +category-list-conversations --category-id 1 --exclude-muted --yes
stdout
{
-  "conversations": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "conversations": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1445ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +category-list-conversations --category-id 1 --exclude-muted --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listConversationsByCategoryV2 error\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840997724375834e0758"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listConversationsByCategoryV2 error\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840997724375834e0758"
-  }
-}
dws chat +category-remove-conversation --group <group> --category-ids <category-ids>write
将会话从指定的自定义分组中移出
dws mcp im remove_conv_from_categories --json '{"openConversationId":<..>, "categoryIds":<..>}'lark im 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--category-ids: invalid integer "x"
输入 / CLI 输出
输入
dws chat +category-remove-conversation --group x --category-ids x --yes
stdout
(空)
stderr / error
--category-ids: invalid integer "x"
最终用于判定的返回
--category-ids: invalid integer "x"
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1309ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test chat +category-remove-conversation --group cidDWSREALTESTNOSUCHCONV --category-ids 999999999999 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999130466520e085e"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999130466520e085e"
-  }
-}
dws chat +category-rename --category-id <category-id> --title <title>write
更新用户自定义会话分组的名称
dws mcp im rename_conv_category --json '{"categoryId":<..>, "title":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/rename_conv_category
输入 / CLI 输出
输入
dws chat +category-rename --category-id 1 --title x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "categoryId": 1,
-    "title": "x"
-  },
-  "product": "im",
-  "tool": "rename_conv_category"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "categoryId": 1,
-    "title": "x"
-  },
-  "product": "im",
-  "tool": "rename_conv_category"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1225ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +category-rename --category-id 1784081668329 --title 'DWS改0715' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-add-bot --robot-code <robot-code> --id <id>write
将机器人添加到群中
dws mcp bot add_robot_to_group --json '{"robotCode":<..>, "openConversationId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/add_robot_to_group
输入 / CLI 输出
输入
dws chat +chat-add-bot --robot-code x --id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "robotCode": "x"
-  },
-  "product": "bot",
-  "tool": "add_robot_to_group"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "robotCode": "x"
-  },
-  "product": "bot",
-  "tool": "add_robot_to_group"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1273ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test chat +chat-add-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "500025",
-    "server_key": "bot",
-    "trace_id": "2104a64c17840999144193460e08c7"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "500025",
-    "server_key": "bot",
-    "trace_id": "2104a64c17840999144193460e08c7"
-  }
-}
dws chat +chat-audit-join --group <group> --record-id <record-id> --applicant <applicant> --inviter <inviter> --status <status> --description <description>write
审批入群验证(通过/拒绝/删除/忽略/拉黑)
dws mcp im audit_join_group --json '{"openConversationId":<..>, "applyRecordId":<..>, "applicantUid":<..>, "inviterUid":<..>, "status":<..>, "auditDescription":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/audit_join_group
输入 / CLI 输出
输入
dws chat +chat-audit-join --group x --record-id 1 --applicant x --inviter x --status AuditApprove --description x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "applicantUid": "x",
-    "applyRecordId": 1,
-    "auditDescription": "x",
-    "inviterUid": "x",
-    "openConversationId": "x",
-    "status": "AuditApprove"
-  },
-  "product": "im",
-  "tool": "audit_join_group"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "applicantUid": "x",
-    "applyRecordId": 1,
-    "auditDescription": "x",
-    "inviterUid": "x",
-    "openConversationId": "x",
-    "status": "AuditApprove"
-  },
-  "product": "im",
-  "tool": "audit_join_group"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1073ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-dry-run 已证明 CLI 装配了 applicantUid/inviterUid;真实后端仍报 applicantUid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +chat-audit-join --group cidDWSREALTESTNOSUCHCONV --record-id 999999999999 --applicant 103262 --inviter 519019 --status AuditApprove --description 'DWS shortcut 真实测试描述,可删除' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999154832733e0758"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999154832733e0758"
-  }
-}
dws chat +chat-bots --group <group>read
查看群内所有机器人
dws mcp bot <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/list_group_bots
输入 / CLI 输出
输入
dws chat +chat-bots --group x --yes
stdout
{
-  "bots": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "bots": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=907ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-bots --group cid3Jijzhe2aqs9ysOXjhi05g== --yes --format json
真实 stdout
{
-  "bots": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "bots": [],
-  "count": 0
-}
dws chat +chat-dismiss --group <group>high-risk-write
解散群聊(不可逆,需群主权限)
dws mcp im dismiss_group --json '{"openConversationId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/dismiss_group
输入 / CLI 输出
输入
dws chat +chat-dismiss --group x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "dismiss_group"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "dismiss_group"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1120ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-dismiss --group cidXgIVDDzZE7kdQ+bcp3gWNw== --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-get-by-id --group-id <group-id>read
根据群号获取群聊信息
dws mcp im get_conv_info_by_group_id --json '{"groupId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/get_conv_info_by_group_id
输入 / CLI 输出
输入
dws chat +chat-get-by-id --group-id 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "groupId": 1
-  },
-  "product": "im",
-  "tool": "get_conv_info_by_group_id"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "groupId": 1
-  },
-  "product": "im",
-  "tool": "get_conv_info_by_group_id"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1462ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +chat-get-by-id --group-id 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840997739165535e07bd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840997739165535e07bd"
-  }
-}
dws chat +chat-invite-url --group <group> --expires-seconds <expires-seconds>read
获取群邀请链接
dws mcp im get_group_invite_url --json '{"openConversationId":<..>, "cid":<..>, "expiresSeconds":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/get_group_invite_url
输入 / CLI 输出
输入
dws chat +chat-invite-url --group x --expires-seconds 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "expiresSeconds": 1,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "get_group_invite_url"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "expiresSeconds": 1,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "get_group_invite_url"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=958ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-invite-url --group cid3Jijzhe2aqs9ysOXjhi05g== --expires-seconds 1 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "inviteUrl": "https://qr.dingtalk.com/action/joingroup?code=v1,k1,Rj9aR+97xuRdK0cKsvoU/daj0MZj7p02n7pETjkgcMo=\u0026_dt_no_comment=1\u0026origin=11"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "inviteUrl": "https://qr.dingtalk.com/action/joingroup?code=v1,k1,Rj9aR+97xuRdK0cKsvoU/daj0MZj7p02n7pETjkgcMo=\u0026_dt_no_comment=1\u0026origin=11"
-  },
-  "success": true
-}
dws chat +chat-list-all --limit <limit> --cursor <cursor>read
分页拉取我加入的所有群列表
dws mcp im <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_my_groups_pagination
输入 / CLI 输出
输入
dws chat +chat-list-all --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "groups": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "groups": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1043ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-list-all --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 9,
-  "groups": [
-    {
-      "name": "浅曦-kida,Dennis,秋画",
-      "openConversationId": "cid3Jijzhe2aqs9ysOXjhi05g=="
-    },
-    {
-      "name": "Dennis,梨岚,浅曦,秋画",
-      "openConversationId": "cidBRb3r823j/rs8mGdPervyQ=="
-    },
-    {
-      "name": "HISTORE_DINGDING_VOIP_APP",
-      "openConversationId": "cid68WRQRLAWFUvSWPjFTxQEg=="
-    },
-    {
-      "name": "钉钉19届应届生校招群",
-      "openConversationId": "cidsXjWvcYMguAWhhE6x1PsRw=="
-    },
-    {
-      "name": "钉钉尖角班第21期欢迎你!",
-      "openConversationId": "cid2YTSHeguqsO/PjSu33/IRA=="
-    },
-    {
-      "name": "钉子户",
-      "openConversationId": "cidCVNiNIaY1vsA6Hp0rUvk7w=="
-    },
-    {
-      "name": "应用通知",
-      "openConversationId": "cidPrcdDg1wZb3/0oQRIWxVRw=="
-    },
-    {
-      "name": "三口之家",
-      "openConversationId": "cid8eLJsKOY3EwulLUqD3jVbA=="
-    },
-    {
-      "name": "工作通知:三口之家",
-      "openConversationId": "cidAHnUfD5n2F+Ybdsqeu9ymQ=="
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 9,
-  "groups": [
-    {
-      "name": "浅曦-kida,Dennis,秋画",
-      "openConversationId": "cid3Jijzhe2aqs9ysOXjhi05g=="
-    },
-    {
-      "name": "Dennis,梨岚,浅曦,秋画",
-      "openConversationId": "cidBRb3r823j/rs8mGdPervyQ=="
-    },
-    {
-      "name": "HISTORE_DINGDING_VOIP_APP",
-      "openConversationId": "cid68WRQRLAWFUvSWPjFTxQEg=="
-    },
-    {
-      "name": "钉钉19届应届生校招群",
-      "openConversationId": "cidsXjWvcYMguAWhhE6x1PsRw=="
-    },
-    {
-      "name": "钉钉尖角班第21期欢迎你!",
-      "openConversationId": "cid2YTSHeguqsO/PjSu33/IRA=="
-    },
-    {
-      "name": "钉子户",
-      "openConversationId": "cidCVNiNIaY1vsA6Hp0rUvk7w=="
-    },
-    {
-      "name": "应用通知",
-      "openConversationId": "cidPrcdDg1wZb3/0oQRIWxVRw=="
-    },
-    {
-      "name": "三口之家",
-      "openConversationId": "cid8eLJsKOY3EwulLUqD3jVbA=="
-    },
-    {
-      "name": "工作通知:三口之家",
-      "openConversationId": "cidAHnUfD5n2F+Ybdsqeu9ymQ=="
-    }
-  ]
-}
dws chat +chat-list-join-requests --limit <limit> --cursor <cursor>read
分页拉取入群验证记录
dws mcp im list_apply_join_group_records --json '{"limit":<..>, "cursor":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_apply_join_group_records
输入 / CLI 输出
输入
dws chat +chat-list-join-requests --limit 1 --cursor x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cursor": "x",
-    "limit": 1
-  },
-  "product": "im",
-  "tool": "list_apply_join_group_records"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cursor": "x",
-    "limit": 1
-  },
-  "product": "im",
-  "tool": "list_apply_join_group_records"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=960ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-list-join-requests --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "hasMore": false,
-    "nextCursor": 0,
-    "records": []
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "hasMore": false,
-    "nextCursor": 0,
-    "records": []
-  },
-  "success": true
-}
dws chat +chat-list-mine --role <role> --limit <limit> --exclude-muted <exclude-muted>read
拉取我创建/管理的群
dws mcp im <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_owned_or_admin_groups
输入 / CLI 输出
输入
dws chat +chat-list-mine --role OWNER --limit 1 --exclude-muted --yes
stdout
{
-  "count": 0,
-  "groups": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "groups": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1799ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-list-mine --role OWNER --limit 10 --exclude-muted --yes --format json
真实 stdout
{
-  "count": 10,
-  "groups": [
-    {
-      "name": "DING_VIBE_CODING没统计到token的问题",
-      "openConversationId": "cidl/foiVj/HRZjBxQI4+Y81g=="
-    },
-    {
-      "name": "钉钉 EveryDay X RELU 共创",
-      "openConversationId": "cid7XlLm5toebOyqw0J3GJfmA=="
-    },
-    {
-      "name": "中控测试",
-      "openConversationId": "cidwnTdZmw2YMznZKLOmws9VQ=="
-    },
-    {
-      "name": "国粹候选人",
-      "openConversationId": "cidw+0WK0/N8nrKwvcgpg6AJQ=="
-    },
-    {
-      "name": "X 项目算法支持",
-      "openConversationId": "cidbDGRPLyhdI1/b85MiUSz5Q=="
-    },
-    {
-      "name": "悟空 Agent Runtime Scrum",
-      "openConversationId": "cidCAHeQDMT93RTArxiqBJLiA=="
-    },
-    {
-      "name": "电商刊登接入CodeAgent",
-      "openConversationId": "cid6IhuGpSIpuosGTGusYUc2g=="
-    },
-    {
-      "name": "0529机器人 && 移动端中控问题跟进",
-      "openConversationId": "cidXMfbCGTSiHzLLaRV5WDXgw=="
-    },
-    {
-      "name": "念晨,伊玲,双数",
-      "openConversationId": "cid/xM8a62lWiQGkfgLi4kfhA=="
-    },
-    {
-      "name": "方齐,念晨,向尘",
-      "openConversationId": "cidpG74/BF0DPbkIHS42XPZZg=="
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 10,
-  "groups": [
-    {
-      "name": "DING_VIBE_CODING没统计到token的问题",
-      "openConversationId": "cidl/foiVj/HRZjBxQI4+Y81g=="
-    },
-    {
-      "name": "钉钉 EveryDay X RELU 共创",
-      "openConversationId": "cid7XlLm5toebOyqw0J3GJfmA=="
-    },
-    {
-      "name": "中控测试",
-      "openConversationId": "cidwnTdZmw2YMznZKLOmws9VQ=="
-    },
-    {
-      "name": "国粹候选人",
-      "openConversationId": "cidw+0WK0/N8nrKwvcgpg6AJQ=="
-    },
-    {
-      "name": "X 项目算法支持",
-      "openConversationId": "cidbDGRPLyhdI1/b85MiUSz5Q=="
-    },
-    {
-      "name": "悟空 Agent Runtime Scrum",
-      "openConversationId": "cidCAHeQDMT93RTArxiqBJLiA=="
-    },
-    {
-      "name": "电商刊登接入CodeAgent",
-      "openConversationId": "cid6IhuGpSIpuosGTGusYUc2g=="
-    },
-    {
-      "name": "0529机器人 && 移动端中控问题跟进",
-      "openConversationId": "cidXMfbCGTSiHzLLaRV5WDXgw=="
-    },
-    {
-      "name": "念晨,伊玲,双数",
-      "openConversationId": "cid/xM8a62lWiQGkfgLi4kfhA=="
-    },
-    {
-      "name": "方齐,念晨,向尘",
-      "openConversationId": "cidpG74/BF0DPbkIHS42XPZZg=="
-    }
-  ]
-}
dws chat +chat-members-get --id <id> --users <users>read
根据成员 openDingTalkId 批量查询群成员详情
dws mcp im list_group_member_by_ids --json '{"openConversationId":<..>, "cid":<..>, "memberOpenDingTalkIds":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_group_member_by_ids
输入 / CLI 输出
输入
dws chat +chat-members-get --id x --users x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "memberOpenDingTalkIds": [
-      "x"
-    ],
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "list_group_member_by_ids"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "memberOpenDingTalkIds": [
-      "x"
-    ],
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "list_group_member_by_ids"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1518ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +chat-members-get --id DWSREALREADNOSUCHID0000000000000 --users '冬翔' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840997754345760e07bd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840997754345760e07bd"
-  }
-}
dws chat +chat-messages --group <group> --user <user> --time <time> --limit <limit> --direction <direction>read复合/智能型
拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);参数 --group、--user 只能指定其一(当前指定了 --group、--user)
输入 / CLI 输出
输入
dws chat +chat-messages --group x --user x --time x --limit 1 --direction x --yes
stdout
(空)
stderr / error
参数 --group、--user 只能指定其一(当前指定了 --group、--user)
最终用于判定的返回
参数 --group、--user 只能指定其一(当前指定了 --group、--user)
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1328ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +chat-messages --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --limit 10 --direction older --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openCid or cid is required\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "chat",
-    "trace_id": "2104a64c17840997767792656e085e"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openCid or cid is required\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "chat",
-    "trace_id": "2104a64c17840997767792656e085e"
-  }
-}
dws chat +chat-mute --group <group> --off <off>write
全员禁言 / 取消全员禁言
dws mcp im set_group_mute --json '{"openConversationId":<..>, "mute":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/set_group_mute
输入 / CLI 输出
输入
dws chat +chat-mute --group x --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "set_group_mute"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "set_group_mute"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1252ms;real-backend-manual-write cleanup/unmute; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-mute --group cidXgIVDDzZE7kdQ+bcp3gWNw== --off --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-mute-member --group <group> --users <users> --mute-time <mute-time> --off <off>write
指定群成员禁言 / 取消禁言
dws mcp im set_group_member_mute_list --json '{"openConversationId":<..>, "cid":<..>, "mute":<..>, "uids":<..>, "openDingTalkIds":<..>, "muteTime":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/set_group_member_mute_list
输入 / CLI 输出
输入
dws chat +chat-mute-member --group x --users x --mute-time 1 --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x",
-    "uids": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "set_group_member_mute_list"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x",
-    "uids": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "set_group_member_mute_list"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1139ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +chat-mute-member --group cidDWSREALTESTNOSUCHCONV --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --mute-time 1 --off --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999166036583e08a3"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999166036583e08a3"
-  }
-}
dws chat +chat-quit --group <group>write
退出群聊
dws mcp im quit_group --json '{"openConversationId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/quit_group
输入 / CLI 输出
输入
dws chat +chat-quit --group x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "quit_group"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "quit_group"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1162ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +chat-quit --group cidDWSREALTESTNOSUCHCONV --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999176728426e0779"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999176728426e0779"
-  }
-}
dws chat +chat-remove-bot --id <id> --bot-id <bot-id>high-risk-write
从群内移除机器人
dws mcp bot remove_robot_in_group --json '{"openConversationId":<..>, "openBotId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/remove_robot_in_group
输入 / CLI 输出
输入
dws chat +chat-remove-bot --id x --bot-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openBotId": "x",
-    "openConversationId": "x"
-  },
-  "product": "bot",
-  "tool": "remove_robot_in_group"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openBotId": "x",
-    "openConversationId": "x"
-  },
-  "product": "bot",
-  "tool": "remove_robot_in_group"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1220ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +chat-remove-bot --id DWSREALTESTNOSUCHID0000000000000 --bot-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "500009",
-    "server_key": "bot",
-    "trace_id": "0b5deb3217840999189888305e08b5"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "500009",
-    "server_key": "bot",
-    "trace_id": "0b5deb3217840999189888305e08b5"
-  }
-}
dws chat +chat-role-add --group <group> --name <name>write
添加群身份
dws mcp im add_custom_group_role --json '{"openConversationId":<..>, "name":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/add_custom_group_role
输入 / CLI 输出
输入
dws chat +chat-role-add --group x --name x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "name": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "add_custom_group_role"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "name": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "add_custom_group_role"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1281ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-role-add --group cidXgIVDDzZE7kdQ+bcp3gWNw== --name 'DWS测试角色' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openRoleId": "role9G67RdPFmhF1VxBpguhr1QiEiE"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openRoleId": "role9G67RdPFmhF1VxBpguhr1QiEiE"
-  },
-  "success": true
-}
dws chat +chat-role-list --group <group>read
拉取会话的群身份列表
dws mcp im <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_custom_group_roles
输入 / CLI 输出
输入
dws chat +chat-role-list --group x --yes
stdout
{
-  "count": 0,
-  "roles": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "roles": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=928ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-role-list --group cid3Jijzhe2aqs9ysOXjhi05g== --yes --format json
真实 stdout
{
-  "count": 0,
-  "roles": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "roles": []
-}
dws chat +chat-role-query-user --group <group> --user <user>read
查询群成员的群身份
dws mcp im query_custom_user_roles --json '{"openConversationId":<..>, "openDingTalkId":<..>, "userId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/query_custom_user_roles
输入 / CLI 输出
输入
dws chat +chat-role-query-user --group x --user x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "userId": "x"
-  },
-  "product": "im",
-  "tool": "query_custom_user_roles"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "userId": "x"
-  },
-  "product": "im",
-  "tool": "query_custom_user_roles"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=958ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-role-query-user --group cid3Jijzhe2aqs9ysOXjhi05g== --user '冬翔' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "roles": []
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "roles": []
-  },
-  "success": true
-}
dws chat +chat-role-remove --group <group> --role-id <role-id>high-risk-write
删除群身份
dws mcp im remove_custom_group_role --json '{"openConversationId":<..>, "openRoleId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/remove_custom_group_role
输入 / CLI 输出
输入
dws chat +chat-role-remove --group x --role-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openRoleId": "x"
-  },
-  "product": "im",
-  "tool": "remove_custom_group_role"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openRoleId": "x"
-  },
-  "product": "im",
-  "tool": "remove_custom_group_role"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1054ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +chat-role-remove --group cidDWSREALTESTNOSUCHCONV --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999200483204e079c"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999200483204e079c"
-  }
-}
dws chat +chat-role-remove-user --group <group> --user <user> --role-ids <role-ids>write
移除用户的指定群身份
dws mcp im remove_custom_user_roles --json '{"openConversationId":<..>, "openRoleIds":<..>, "openDingTalkId":<..>, "userId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/remove_custom_user_roles
输入 / CLI 输出
输入
dws chat +chat-role-remove-user --group x --user x --role-ids x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openRoleIds": [
-      "x"
-    ],
-    "userId": "x"
-  },
-  "product": "im",
-  "tool": "remove_custom_user_roles"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openRoleIds": [
-      "x"
-    ],
-    "userId": "x"
-  },
-  "product": "im",
-  "tool": "remove_custom_user_roles"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1104ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +chat-role-remove-user --group cidDWSREALTESTNOSUCHCONV --user __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --role-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999211317952e0757"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999211317952e0757"
-  }
-}
dws chat +chat-role-set-user --group <group> --user <user> --role-ids <role-ids>write
设置用户的群身份(覆盖该用户的全部群身份)
dws mcp im set_custom_user_roles --json '{"openConversationId":<..>, "openRoleIds":<..>, "openDingTalkId":<..>, "userId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/set_custom_user_roles
输入 / CLI 输出
输入
dws chat +chat-role-set-user --group x --user x --role-ids x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openRoleIds": [
-      "x"
-    ],
-    "userId": "x"
-  },
-  "product": "im",
-  "tool": "set_custom_user_roles"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openRoleIds": [
-      "x"
-    ],
-    "userId": "x"
-  },
-  "product": "im",
-  "tool": "set_custom_user_roles"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1355ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-role-set-user --group cidXgIVDDzZE7kdQ+bcp3gWNw== --user 103262 --role-ids role9G67RdPFmhF1VxBpguhr1QiEiE --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-role-update --group <group> --role-id <role-id> --name <name>write
更新群身份名称
dws mcp im update_custom_group_role --json '{"openConversationId":<..>, "openRoleId":<..>, "name":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_custom_group_role
输入 / CLI 输出
输入
dws chat +chat-role-update --group x --role-id x --name x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "name": "x",
-    "openConversationId": "x",
-    "openRoleId": "x"
-  },
-  "product": "im",
-  "tool": "update_custom_group_role"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "name": "x",
-    "openConversationId": "x",
-    "openRoleId": "x"
-  },
-  "product": "im",
-  "tool": "update_custom_group_role"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1366ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-role-update --group cidXgIVDDzZE7kdQ+bcp3gWNw== --role-id role9G67RdPFmhF1VxBpguhr1QiEiE --name 'DWS角色改' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-search --query <query> --limit <limit> --cursor <cursor> --exclude-muted <exclude-muted>read
按关键词搜索群聊
dws mcp im search_groups --json '{"keyword":<..>, "limit":<..>, "cursor":<..>, "excludeMuted":<..>}'lark-cli im +chat-search已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/search_groups
输入 / CLI 输出
输入
dws chat +chat-search --query x --limit 1 --cursor x --exclude-muted --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cursor": "x",
-    "excludeMuted": true,
-    "keyword": "x",
-    "limit": 1
-  },
-  "product": "im",
-  "tool": "search_groups"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cursor": "x",
-    "excludeMuted": true,
-    "keyword": "x",
-    "limit": 1
-  },
-  "product": "im",
-  "tool": "search_groups"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1176ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-search --query '测试' --limit 10 --cursor 0 --exclude-muted --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "groups": [
-      {
-        "channel": false,
-        "createAt": "2026-06-09 09:21:07",
-        "extension": {
-          "newCSpaceIdIM": "29001572040"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 4,
-        "openConversationId": "cidYmSrmbqMM/OseCzcs4cJdg==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "测试群已废弃"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-06-09 09:21:00",
-        "extension": {
-          "newCSpaceIdIM": "29001813316"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 3,
-        "openConversationId": "cidSIMPP2TD6HZcn1nO0mVNoA==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "测试群已废弃"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-05-13 16:22:30",
-        "extension": {
-          "newCSpaceIdIM": "28540426059"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 70,
-        "openConversationId": "cidU38vTWZbweyBRT71Md/KJQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UXSDzdMWL3Ls3VUvhTCwbT",
-        "title": "测试项目沟通群"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-04-24 11:49:36",
-        "extension": {
-          "newCSpaceIdIM": "28740101247"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 2,
-        "openConversationId": "cidOpAz1EH8TLuD9H1Who48pw==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "测试项目沟通群"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-06-26 14:05:19",
-        "extension": {
-          "newCSpaceIdIM": "29101779299"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 2,
-        "openConversationId": "cidp+Oj7m1gJnx+bCtTn3vhcQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "Spur测试"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-07-02 11:26:29",
-        "extension": {
-          "newCSpaceIdIM": "29137611383"
-        },
-        "groupType": "NEW_EXTERNAL_GROUP",
-        "memberCount": 1,
-        "openConversationId": "cidwnTdZmw2YMznZKLOmws9VQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "中控测试"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-06-09 11:03:12",
-        "extension": {
-          "newCSpaceIdIM": "29002815625"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 2,
-        "openConversationId": "cid3hq0VEj6C6FtEtDsWCtNwg==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "评测复盘讨论群-测试"
-      },
-      {
-        "channel": true,
-        "createAt": "2026-03-24 18:10:34",
-        "extension": {
-          "newCSpaceIdIM": "28494427717"
-        },
-        "groupType": "NEW_EXTERNAL_GROUP",
-        "memberCount": 2628,
-        "openConversationId": "cidm6kkku3iW/ddE/DuoWOEnQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3XSNFHTUgOrXFyf7hpLIgii2",
-        "title": "悟空问题反馈群"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-05-13 16:23:30",
-        "extension": {
-          "newCSpaceIdIM": "28540433569"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 71,
-        "openConversationId": "cidzoNLz/wgp7gRm/mRph+MxA==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UXSDzdMWL3Ls3VUvhTCwbT",
-        "title": "日常消息同步群"
-      }
-    ],
-    "hasMore": true,
-    "nextCursor": "da8b2b5ea0a50c4ae90dcf38edde9f52425c3bb1dd7cb1eddf53e7db6b73b3c4299e03fd01b0cf917b20e2f46a4f5e13bb7a1bd722cf6c4a372c6971d3c37fb7a3301ae0e438d2556a38ae1fc9c6ee9c110732c3d5d829998a367dc7ebd3a0f639e2dc0841255be944cf07a406575e4cbee22cfca7f309a6bba4d6c35f7d3bd05ce9af894fd2f107f264c0c16d61f6c778e2c2e5c3ea05bc0f77ae6cd7bf402f77218ef7439e2483e5348b3cc40b6a28eeb87bc58925bff5a69d0b080d3818ef1eed93958ffb401cb286b4e885564950f46fa42ad85b4161fd5752f840865648c4e2a89a26f8e241ee476d17a8a2352feb4e1f7d97b28d69aeba429b1e6565fd9acc3e2aa71ec6c05d0a31d90c451aa3"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "groups": [
-      {
-        "channel": false,
-        "createAt": "2026-06-09 09:21:07",
-        "extension": {
-          "newCSpaceIdIM": "29001572040"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 4,
-        "openConversationId": "cidYmSrmbqMM/OseCzcs4cJdg==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "测试群已废弃"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-06-09 09:21:00",
-        "extension": {
-          "newCSpaceIdIM": "29001813316"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 3,
-        "openConversationId": "cidSIMPP2TD6HZcn1nO0mVNoA==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "测试群已废弃"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-05-13 16:22:30",
-        "extension": {
-          "newCSpaceIdIM": "28540426059"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 70,
-        "openConversationId": "cidU38vTWZbweyBRT71Md/KJQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UXSDzdMWL3Ls3VUvhTCwbT",
-        "title": "测试项目沟通群"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-04-24 11:49:36",
-        "extension": {
-          "newCSpaceIdIM": "28740101247"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 2,
-        "openConversationId": "cidOpAz1EH8TLuD9H1Who48pw==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "测试项目沟通群"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-06-26 14:05:19",
-        "extension": {
-          "newCSpaceIdIM": "29101779299"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 2,
-        "openConversationId": "cidp+Oj7m1gJnx+bCtTn3vhcQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "Spur测试"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-07-02 11:26:29",
-        "extension": {
-          "newCSpaceIdIM": "29137611383"
-        },
-        "groupType": "NEW_EXTERNAL_GROUP",
-        "memberCount": 1,
-        "openConversationId": "cidwnTdZmw2YMznZKLOmws9VQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "中控测试"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-06-09 11:03:12",
-        "extension": {
-          "newCSpaceIdIM": "29002815625"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 2,
-        "openConversationId": "cid3hq0VEj6C6FtEtDsWCtNwg==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "title": "评测复盘讨论群-测试"
-      },
-      {
-        "channel": true,
-        "createAt": "2026-03-24 18:10:34",
-        "extension": {
-          "newCSpaceIdIM": "28494427717"
-        },
-        "groupType": "NEW_EXTERNAL_GROUP",
-        "memberCount": 2628,
-        "openConversationId": "cidm6kkku3iW/ddE/DuoWOEnQ==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3XSNFHTUgOrXFyf7hpLIgii2",
-        "title": "悟空问题反馈群"
-      },
-      {
-        "channel": false,
-        "createAt": "2026-05-13 16:23:30",
-        "extension": {
-          "newCSpaceIdIM": "28540433569"
-        },
-        "groupType": "INTERNAL_GROUP",
-        "memberCount": 71,
-        "openConversationId": "cidzoNLz/wgp7gRm/mRph+MxA==",
-        "ownerOpenDingtalkId": "D1jIqGatuH3UXSDzdMWL3Ls3VUvhTCwbT",
-        "title": "日常消息同步群"
-      }
-    ],
-    "hasMore": true,
-    "nextCursor": "da8b2b5ea0a50c4ae90dcf38edde9f52425c3bb1dd7cb1eddf53e7db6b73b3c4299e03fd01b0cf917b20e2f46a4f5e13bb7a1bd722cf6c4a372c6971d3c37fb7a3301ae0e438d2556a38ae1fc9c6ee9c110732c3d5d829998a367dc7ebd3a0f639e2dc0841255be944cf07a406575e4cbee22cfca7f309a6bba4d6c35f7d3bd05ce9af894fd2f107f264c0c16d61f6c778e2c2e5c3ea05bc0f77ae6cd7bf402f77218ef7439e2483e5348b3cc40b6a28eeb87bc58925bff5a69d0b080d3818ef1eed93958ffb401cb286b4e885564950f46fa42ad85b4161fd5752f840865648c4e2a89a26f8e241ee476d17a8a2352feb4e1f7d97b28d69aeba429b1e6565fd9acc3e2aa71ec6c05d0a31d90c451aa3"
-  },
-  "success": true
-}
dws chat +chat-set-admin --group <group> --users <users> --off <off>write
设置 / 取消群管理员
dws mcp im update_conv_member_roles --json '{"openConversationId":<..>, "admin":<..>, "uids":<..>, "openDingTalkIds":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_conv_member_roles
输入 / CLI 输出
输入
dws chat +chat-set-admin --group x --users x --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "admin": false,
-    "openConversationId": "x",
-    "uids": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "update_conv_member_roles"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "admin": false,
-    "openConversationId": "x",
-    "uids": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "update_conv_member_roles"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1554ms;real-backend-manual-write cleanup/unset-admin; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-set-admin --group cidXgIVDDzZE7kdQ+bcp3gWNw== --users 103262 --off --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-set-history --group <group> --option <option>write
设置新成员入群可查看历史消息范围
dws mcp im update_show_history_msg_option --json '{"openConversationId":<..>, "option":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_show_history_msg_option
输入 / CLI 输出
输入
dws chat +chat-set-history --group x --option FORBIDDEN --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "option": "FORBIDDEN"
-  },
-  "product": "im",
-  "tool": "update_show_history_msg_option"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "option": "FORBIDDEN"
-  },
-  "product": "im",
-  "tool": "update_show_history_msg_option"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1219ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-set-history --group cidXgIVDDzZE7kdQ+bcp3gWNw== --option RECENT_100 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-transfer-owner --group <group> --new-owner <new-owner>write
转让群主
dws mcp im transfer_group_owner --json '{"openConversationId":<..>, "cid":<..>, "newOwnerOpenDingTalkId":<..>, "newOwnerUid":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/transfer_group_owner
输入 / CLI 输出
输入
dws chat +chat-transfer-owner --group x --new-owner x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "newOwnerUid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "transfer_group_owner"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "newOwnerUid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "transfer_group_owner"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1054ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +chat-transfer-owner --group cidDWSREALTESTNOSUCHCONV --new-owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999222318863e087a"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999222318863e087a"
-  }
-}
dws chat +chat-update-alias --group <group> --alias-title <alias-title>write
设置群备注(仅自己可见)
dws mcp im update_user_group_alias --json '{"openConversationId":<..>, "aliasTitle":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_user_group_alias
输入 / CLI 输出
输入
dws chat +chat-update-alias --group x --alias-title x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "aliasTitle": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_user_group_alias"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "aliasTitle": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_user_group_alias"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1298ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-update-alias --group cidXgIVDDzZE7kdQ+bcp3gWNw== --alias-title 'DWS群备注' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-update-icon --group <group> --icon-media-id <icon-media-id>write
更新群头像
dws mcp im update_group_icon --json '{"openConversationId":<..>, "iconMediaId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_group_icon
输入 / CLI 输出
输入
dws chat +chat-update-icon --group x --icon-media-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "iconMediaId": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_group_icon"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "iconMediaId": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_group_icon"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1023ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +chat-update-icon --group cidDWSREALTESTNOSUCHCONV --icon-media-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999232478654e0819"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999232478654e0819"
-  }
-}
dws chat +chat-update-nick --group <group> --nick <nick>write
设置当前用户在群内的群昵称
dws mcp im update_group_nick --json '{"openConversationId":<..>, "nick":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_group_nick
输入 / CLI 输出
输入
dws chat +chat-update-nick --group x --nick x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nick": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_group_nick"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nick": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_group_nick"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1478ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +chat-update-nick --group cidXgIVDDzZE7kdQ+bcp3gWNw== --nick 'DWS测试' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +chat-update-settings --group <group> --setting-key <setting-key> --status <status>write
更新群设置(settingKey + status)
dws mcp im update_group_settings --json '{"openConversationId":<..>, "settingKey":<..>, "status":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_group_settings
输入 / CLI 输出
输入
dws chat +chat-update-settings --group x --setting-key x --status 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "settingKey": "x",
-    "status": 1
-  },
-  "product": "im",
-  "tool": "update_group_settings"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "settingKey": "x",
-    "status": 1
-  },
-  "product": "im",
-  "tool": "update_group_settings"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1184ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +chat-update-settings --group cidDWSREALTESTNOSUCHCONV --setting-key x --status 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999244326971e07dd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999244326971e07dd"
-  }
-}
dws chat +conversation-clear-all-red-pointwrite
清除所有会话红点(全部已读)
dws mcp im clear_all_red_pointlark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/clear_all_red_point
输入 / CLI 输出
输入
dws chat +conversation-clear-all-red-point --yes
stdout
{
-  "_fake": true,
-  "args": {},
-  "product": "im",
-  "tool": "clear_all_red_point"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {},
-  "product": "im",
-  "tool": "clear_all_red_point"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=13459ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +conversation-clear-all-red-point --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +conversation-clear-messages --conversation-id <conversation-id>high-risk-write
清空当前用户指定会话的聊天记录(仅本人视角,不可逆)
dws mcp im clear_conversation_messages --json '{"openConversationId":<..>, "cid":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/clear_conversation_messages
输入 / CLI 输出
输入
dws chat +conversation-clear-messages --conversation-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "clear_conversation_messages"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "clear_conversation_messages"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1065ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-clear-messages --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999254816721e079b"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999254816721e079b"
-  }
-}
dws chat +conversation-clear-red-point --conversation-id <conversation-id>write
清除会话红点
dws mcp im clear_conversation_red_point --json '{"openConversationId":<..>, "cid":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/clear_conversation_red_point
输入 / CLI 输出
输入
dws chat +conversation-clear-red-point --conversation-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "clear_conversation_red_point"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "clear_conversation_red_point"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1044ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-clear-red-point --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999265511295e085f"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999265511295e085f"
-  }
-}
dws chat +conversation-hide --conversation-id <conversation-id>write
会话列表中隐藏会话(收到新消息会重新出现)
dws mcp im hide_conversation --json '{"openConversationId":<..>, "cid":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/hide_conversation
输入 / CLI 输出
输入
dws chat +conversation-hide --conversation-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "hide_conversation"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "hide_conversation"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1064ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-hide --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999276117140e079b"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999276117140e079b"
-  }
-}
dws chat +conversation-info --group <group> --open-dingtalk-id <open-dingtalk-id>read
获取会话信息(群聊传 --group,单聊传 --open-dingtalk-id)
dws mcp chat get_conversation_info --json '{"openConversationId":<..>, "openDingTalkId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/get_conversation_info
输入 / CLI 输出
输入
dws chat +conversation-info --group x --open-dingtalk-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openDingTalkId": "x"
-  },
-  "product": "chat",
-  "tool": "get_conversation_info"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openDingTalkId": "x"
-  },
-  "product": "chat",
-  "tool": "get_conversation_info"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=942ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +conversation-info --group cid3Jijzhe2aqs9ysOXjhi05g== --open-dingtalk-id x --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "conversationInfo": {
-      "createAt": "2018-11-13 12:39:07",
-      "extension": {
-        "newCSpaceIdIM": "1090404636"
-      },
-      "memberCount": 3,
-      "openConversationId": "cid3Jijzhe2aqs9ysOXjhi05g==",
-      "ownerNick": "浅曦",
-      "singleChat": false,
-      "title": "浅曦-kida,Dennis,秋画"
-    }
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "conversationInfo": {
-      "createAt": "2018-11-13 12:39:07",
-      "extension": {
-        "newCSpaceIdIM": "1090404636"
-      },
-      "memberCount": 3,
-      "openConversationId": "cid3Jijzhe2aqs9ysOXjhi05g==",
-      "ownerNick": "浅曦",
-      "singleChat": false,
-      "title": "浅曦-kida,Dennis,秋画"
-    }
-  },
-  "success": true
-}
dws chat +conversation-list --limit <limit> --cursor <cursor> --exclude-muted <exclude-muted>read
分页获取当前用户的全部会话列表(单聊+群聊)
dws mcp im <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_all_conversations
输入 / CLI 输出
输入
dws chat +conversation-list --limit 1 --cursor 1 --exclude-muted --yes
stdout
{
-  "conversations": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "conversations": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2857ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +conversation-list --limit 10 --cursor 0 --exclude-muted --yes --format json
真实 stdout
{
-  "conversations": [
-    {
-      "conversationName": "AI全栈",
-      "openConversationId": "cidGAcU1FHD+D2n3W6d+SXuhw=="
-    },
-    {
-      "conversationName": "念晨",
-      "openConversationId": "cidLw95vHkBXGePhIxRvleOUOtxfWW7v5vcWfa2fdxfxyg="
-    },
-    {
-      "conversationName": "九韶",
-      "openConversationId": "cidJdiIbpqCvEjbCuwHaHwR4A=="
-    },
-    {
-      "conversationName": "战友会",
-      "openConversationId": "cidK7s9XRz94DFkk4raOLduFw=="
-    },
-    {
-      "conversationName": "生煎包",
-      "openConversationId": "cid2CMDRvYP+Aw5ioOfP+se4+txfWW7v5vcWfa2fdxfxyg="
-    },
-    {
-      "conversationName": "Wukong & Real 日志群",
-      "openConversationId": "ciddYsoBxGQrFWR5zJ8lnpPBg=="
-    },
-    {
-      "conversationName": "粤腾",
-      "openConversationId": "cid72F2hVHMoijoL1CvmWp1ImIo0AJKl0bni7qLL0B05PA="
-    },
-    {
-      "conversationName": "算法团队全员群",
-      "openConversationId": "cidXMThCvAfn35/JRu/JOr6BA=="
-    },
-    {
-      "conversationName": "汇量问题反馈✖️skill/mcp",
-      "openConversationId": "cidTm/YDFJ/jgGFYFe3UF6dzA=="
-    },
-    {
-      "conversationName": "亲橙park舒婷水果优惠1群",
-      "openConversationId": "cid9LvgVwAlIvDBduIER4jFQg=="
-    }
-  ],
-  "count": 10
-}
真实 stderr / error
(空)
真实返回
{
-  "conversations": [
-    {
-      "conversationName": "AI全栈",
-      "openConversationId": "cidGAcU1FHD+D2n3W6d+SXuhw=="
-    },
-    {
-      "conversationName": "念晨",
-      "openConversationId": "cidLw95vHkBXGePhIxRvleOUOtxfWW7v5vcWfa2fdxfxyg="
-    },
-    {
-      "conversationName": "九韶",
-      "openConversationId": "cidJdiIbpqCvEjbCuwHaHwR4A=="
-    },
-    {
-      "conversationName": "战友会",
-      "openConversationId": "cidK7s9XRz94DFkk4raOLduFw=="
-    },
-    {
-      "conversationName": "生煎包",
-      "openConversationId": "cid2CMDRvYP+Aw5ioOfP+se4+txfWW7v5vcWfa2fdxfxyg="
-    },
-    {
-      "conversationName": "Wukong & Real 日志群",
-      "openConversationId": "ciddYsoBxGQrFWR5zJ8lnpPBg=="
-    },
-    {
-      "conversationName": "粤腾",
-      "openConversationId": "cid72F2hVHMoijoL1CvmWp1ImIo0AJKl0bni7qLL0B05PA="
-    },
-    {
-      "conversationName": "算法团队全员群",
-      "openConversationId": "cidXMThCvAfn35/JRu/JOr6BA=="
-    },
-    {
-      "conversationName": "汇量问题反馈✖️skill/mcp",
-      "openConversationId": "cidTm/YDFJ/jgGFYFe3UF6dzA=="
-    },
-    {
-      "conversationName": "亲橙park舒婷水果优惠1群",
-      "openConversationId": "cid9LvgVwAlIvDBduIER4jFQg=="
-    }
-  ],
-  "count": 10
-}
dws chat +conversation-list-top --limit <limit> --cursor <cursor> --exclude-muted <exclude-muted>read
拉取置顶会话列表
dws mcp chat <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/list_top_conversations
输入 / CLI 输出
输入
dws chat +conversation-list-top --limit 1 --cursor 1 --exclude-muted --yes
stdout
{
-  "conversations": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "conversations": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1091ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +conversation-list-top --limit 10 --cursor 0 --exclude-muted --yes --format json
真实 stdout
{
-  "conversations": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "conversations": [],
-  "count": 0
-}
dws chat +conversation-mark-read --conversation-id <conversation-id> --message-id <message-id>write
标记消息已读(该消息及之前的消息都标记为已读)
dws mcp im mark_message_read --json '{"openConversationId":<..>, "openMessageId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/mark_message_read
输入 / CLI 输出
输入
dws chat +conversation-mark-read --conversation-id x --message-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "mark_message_read"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "mark_message_read"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1162ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +conversation-mark-read --conversation-id cidDWSREALTESTNOSUCHCONV --message-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999287654280e07bd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999287654280e07bd"
-  }
-}
dws chat +conversation-mark-unread --conversation-id <conversation-id>write
标记会话为未读
dws mcp im mark_conversation_unread --json '{"openConversationId":<..>, "cid":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/mark_conversation_unread
输入 / CLI 输出
输入
dws chat +conversation-mark-unread --conversation-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "mark_conversation_unread"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "mark_conversation_unread"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1090ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-mark-unread --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999298744910e0758"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999298744910e0758"
-  }
-}
dws chat +conversation-mute --conversation-id <conversation-id> --off <off>write
会话消息免打扰(支持单聊/群聊)
dws mcp im update_notification_off --json '{"openConversationId":<..>, "cid":<..>, "mute":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_notification_off
输入 / CLI 输出
输入
dws chat +conversation-mute --conversation-id x --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_notification_off"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_notification_off"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1052ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-mute --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999309241865e085f"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999309241865e085f"
-  }
-}
dws chat +conversation-mute-at-all --conversation-id <conversation-id> --off <off>write
关闭/开启 @所有人消息提醒
dws mcp im update_at_all_notification_off --json '{"openConversationId":<..>, "cid":<..>, "mute":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_at_all_notification_off
输入 / CLI 输出
输入
dws chat +conversation-mute-at-all --conversation-id x --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_at_all_notification_off"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_at_all_notification_off"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1110ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-mute-at-all --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999320028068e07dd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999320028068e07dd"
-  }
-}
dws chat +conversation-mute-red-envelope --conversation-id <conversation-id> --off <off>write
关闭/开启红包消息提醒
dws mcp im update_red_env_notification_off --json '{"openConversationId":<..>, "cid":<..>, "mute":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_red_env_notification_off
输入 / CLI 输出
输入
dws chat +conversation-mute-red-envelope --conversation-id x --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_red_env_notification_off"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "mute": false,
-    "openConversationId": "x"
-  },
-  "product": "im",
-  "tool": "update_red_env_notification_off"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1001ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-mute-red-envelope --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999330228283e07fe"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999330228283e07fe"
-  }
-}
dws chat +conversation-set-top --conversation-id <conversation-id> --off <off>write
会话置顶 / 取消置顶(支持单聊/群聊)
dws mcp im set_top_conversation --json '{"openConversationId":<..>, "cid":<..>, "top":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/set_top_conversation
输入 / CLI 输出
输入
dws chat +conversation-set-top --conversation-id x --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x",
-    "top": false
-  },
-  "product": "im",
-  "tool": "set_top_conversation"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x",
-    "top": false
-  },
-  "product": "im",
-  "tool": "set_top_conversation"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1102ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +conversation-set-top --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999341302961e07fe"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2127d89817840999341302961e07fe"
-  }
-}
dws chat +dm --to <to> --text <text>write复合/智能型
按姓名直接给某人发单聊消息(自动解析 userId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 send_personal_messagelark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws chat +dm --to x --text x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2547ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +dm --to '冬翔' --text 'DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openTaskId": "y/wM6Lo+9GbIqtILYPv1BZDcMW+2FgnqskgcpdOiMdM="
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openTaskId": "y/wM6Lo+9GbIqtILYPv1BZDcMW+2FgnqskgcpdOiMdM="
-  },
-  "success": true
-}
dws chat +group-members --group <group>read复合/智能型
按群名列出群成员(自动搜群解析 openConversationId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/search_groups;没找到名字匹配 "x" 的群;换用群名里连续的核心词再试。
输入 / CLI 输出
输入
dws chat +group-members --group x --yes
stdout
(空)
stderr / error
没找到名字匹配 "x" 的群;换用群名里连续的核心词再试。
最终用于判定的返回
没找到名字匹配 "x" 的群;换用群名里连续的核心词再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1955ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +group-members --group '浅曦-kida,Dennis,秋画' --yes --format json
真实 stdout
{
-  "count": 3,
-  "members": [
-    {
-      "name": "",
-      "nick": "秋画",
-      "openDingtalkId": "DpDC43OwfSyzvpshro641ZyKOpvhZI2tI",
-      "role": "普通成员"
-    },
-    {
-      "name": "",
-      "nick": "浅曦",
-      "openDingtalkId": "DiPbzolsu3XcIqiPOT4eDpd1MUBtiiFg6Xhx",
-      "role": "群主"
-    },
-    {
-      "name": "",
-      "nick": "念晨",
-      "openDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "role": "普通成员"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 3,
-  "members": [
-    {
-      "name": "",
-      "nick": "秋画",
-      "openDingtalkId": "DpDC43OwfSyzvpshro641ZyKOpvhZI2tI",
-      "role": "普通成员"
-    },
-    {
-      "name": "",
-      "nick": "浅曦",
-      "openDingtalkId": "DiPbzolsu3XcIqiPOT4eDpd1MUBtiiFg6Xhx",
-      "role": "群主"
-    },
-    {
-      "name": "",
-      "nick": "念晨",
-      "openDingtalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "role": "普通成员"
-    }
-  ]
-}
dws chat +messages-add-emoji --conversation-id <conversation-id> --msg-id <msg-id> --emoji <emoji>write
对消息添加 emoji 表情回应
dws mcp im add_emoji_reaction --json '{"openConversationId":<..>, "openMsgId":<..>, "emojiName":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/add_emoji_reaction
输入 / CLI 输出
输入
dws chat +messages-add-emoji --conversation-id x --msg-id x --emoji x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "emojiName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x"
-  },
-  "product": "im",
-  "tool": "add_emoji_reaction"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "emojiName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x"
-  },
-  "product": "im",
-  "tool": "add_emoji_reaction"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1170ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-add-emoji --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emoji '👍' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999352941910e0756"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999352941910e0756"
-  }
-}
dws chat +messages-add-text-emotion --conversation-id <conversation-id> --msg-id <msg-id> --emotion-id <emotion-id> --emotion-name <emotion-name> --text <text> --background-id <background-id>write
对消息添加文字表情回应
dws mcp im add_text_emotion --json '{"openConversationId":<..>, "openMsgId":<..>, "emotionId":<..>, "emotionName":<..>, "text":<..>, "backgroundId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/add_text_emotion
输入 / CLI 输出
输入
dws chat +messages-add-text-emotion --conversation-id x --msg-id x --emotion-id x --emotion-name x --text x --background-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "backgroundId": "x",
-    "emotionId": "x",
-    "emotionName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x",
-    "text": "x"
-  },
-  "product": "im",
-  "tool": "add_text_emotion"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "backgroundId": "x",
-    "emotionId": "x",
-    "emotionName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x",
-    "text": "x"
-  },
-  "product": "im",
-  "tool": "add_text_emotion"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=992ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-add-text-emotion --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emotion-id DWSREALTESTNOSUCHID0000000000000 --emotion-name 'DWS测试表情' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --background-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999362862711e08b5"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999362862711e08b5"
-  }
-}
dws chat +messages-batch-recall-by-bot --robot-code <robot-code> --keys <keys>write
机器人撤回单聊消息
dws mcp bot batch_recall_robot_users_msg --json '{"robotCode":<..>, "processQueryKeys":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/batch_recall_robot_users_msg
输入 / CLI 输出
输入
dws chat +messages-batch-recall-by-bot --robot-code x --keys x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "processQueryKeys": [
-      "x"
-    ],
-    "robotCode": "x"
-  },
-  "product": "bot",
-  "tool": "batch_recall_robot_users_msg"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "processQueryKeys": [
-      "x"
-    ],
-    "robotCode": "x"
-  },
-  "product": "bot",
-  "tool": "batch_recall_robot_users_msg"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1085ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-batch-recall-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --keys DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "retryable": true,
-    "server_error_code": "ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"dingOpenErrcode\":400014,\"errorMsg\":\"错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;\"} ",
-    "trace_id": "2104a64c17840999373456305e0817"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "retryable": true,
-    "server_error_code": "ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"dingOpenErrcode\":400014,\"errorMsg\":\"错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;\"} ",
-    "trace_id": "2104a64c17840999373456305e0817"
-  }
-}
dws chat +messages-batch-send-by-bot --robot-code <robot-code> --title <title> --text <text> --users <users> --open-dingtalk-ids <open-dingtalk-ids> --at-all <at-all>write
机器人批量向用户发送单聊 Markdown 消息
dws mcp bot batch_send_robot_msg_to_users --json '{"robotCode":<..>, "title":<..>, "markdown":<..>, "userIds":<..>, "openDingtalkIds":<..>, "isAtAll":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/batch_send_robot_msg_to_users
输入 / CLI 输出
输入
dws chat +messages-batch-send-by-bot --robot-code x --title x --text x --users x --open-dingtalk-ids x --at-all --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "isAtAll": "true",
-    "markdown": "x",
-    "openDingtalkIds": [
-      "x"
-    ],
-    "robotCode": "x",
-    "title": "x",
-    "userIds": [
-      "x"
-    ]
-  },
-  "product": "bot",
-  "tool": "batch_send_robot_msg_to_users"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "isAtAll": "true",
-    "markdown": "x",
-    "openDingtalkIds": [
-      "x"
-    ],
-    "robotCode": "x",
-    "title": "x",
-    "userIds": [
-      "x"
-    ]
-  },
-  "product": "bot",
-  "tool": "batch_send_robot_msg_to_users"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1211ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-batch-send-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --title 'DWS shortcut 真实测试 20260715-151724' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --open-dingtalk-ids '' --at-all --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "retryable": true,
-    "server_error_code": "ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"dingOpenErrcode\":400106,\"errorMsg\":\"错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;\"} ",
-    "trace_id": "0bb7c36217840999385748776e0757"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "retryable": true,
-    "server_error_code": "ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"dingOpenErrcode\":400106,\"errorMsg\":\"错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;\"} ",
-    "trace_id": "0bb7c36217840999385748776e0757"
-  }
-}
dws chat +messages-combine-forward --src-conversation-id <src-conversation-id> --msg-ids <msg-ids> --dest-conversation-id <dest-conversation-id> --uuid <uuid>write
合并转发多条消息
dws mcp im combine_forward_messages --json '{"srcOpenCid":<..>, "srcOpenMessageIds":<..>, "destOpenCid":<..>, "uuid":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/combine_forward_messages
输入 / CLI 输出
输入
dws chat +messages-combine-forward --src-conversation-id x --msg-ids x --dest-conversation-id x --uuid x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "destOpenCid": "x",
-    "srcOpenCid": "x",
-    "srcOpenMessageIds": [
-      "x"
-    ],
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "combine_forward_messages"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "destOpenCid": "x",
-    "srcOpenCid": "x",
-    "srcOpenMessageIds": [
-      "x"
-    ],
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "combine_forward_messages"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1075ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-combine-forward --src-conversation-id cidDWSREALTESTNOSUCHCONV --msg-ids DWSREALTESTNOSUCHID0000000000000 --dest-conversation-id cidDWSREALTESTNOSUCHCONV --uuid dws-shortcut-real-negative-20260715-151724 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999396596163e08ee"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999396596163e08ee"
-  }
-}
dws chat +messages-create-text-emotion --emotion-name <emotion-name> --text <text> --background-id <background-id>write
创建文字表情(获取 emotionId)
dws mcp im create_text_emotion --json '{"emotionName":<..>, "text":<..>, "backgroundId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/create_text_emotion
输入 / CLI 输出
输入
dws chat +messages-create-text-emotion --emotion-name x --text x --background-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "backgroundId": "x",
-    "emotionName": "x",
-    "text": "x"
-  },
-  "product": "im",
-  "tool": "create_text_emotion"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "backgroundId": "x",
-    "emotionName": "x",
-    "text": "x"
-  },
-  "product": "im",
-  "tool": "create_text_emotion"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1054ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +messages-create-text-emotion --emotion-name 'DWS测试表情' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --background-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "400519",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999407422891e08cd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "400519",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999407422891e08cd"
-  }
-}
dws chat +messages-forward --src-conversation-id <src-conversation-id> --msg-id <msg-id> --dest-conversation-id <dest-conversation-id> --uuid <uuid>write
转发单条消息
dws mcp im forward_message --json '{"srcOpenCid":<..>, "srcOpenMessageId":<..>, "destOpenCid":<..>, "uuid":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/forward_message
输入 / CLI 输出
输入
dws chat +messages-forward --src-conversation-id x --msg-id x --dest-conversation-id x --uuid x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "destOpenCid": "x",
-    "srcOpenCid": "x",
-    "srcOpenMessageId": "x",
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "forward_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "destOpenCid": "x",
-    "srcOpenCid": "x",
-    "srcOpenMessageId": "x",
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "forward_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1090ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-forward --src-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --dest-conversation-id cidDWSREALTESTNOSUCHCONV --uuid dws-shortcut-real-negative-20260715-151724 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999417966407e08ee"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999417966407e08ee"
-  }
-}
dws chat +messages-forward-topic --src-msg-id <src-msg-id> --src-conversation-id <src-conversation-id> --src-thread-id <src-thread-id> --dest-conversation-id <dest-conversation-id>write
转发话题消息到目标会话
dws mcp im forward_topic --json '{"srcOpenMessageId":<..>, "srcOpenConversationId":<..>, "srcOpenConvThreadId":<..>, "destOpenConversationId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/forward_topic
输入 / CLI 输出
输入
dws chat +messages-forward-topic --src-msg-id x --src-conversation-id x --src-thread-id x --dest-conversation-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "destOpenConversationId": "x",
-    "srcOpenConvThreadId": "x",
-    "srcOpenConversationId": "x",
-    "srcOpenMessageId": "x"
-  },
-  "product": "im",
-  "tool": "forward_topic"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "destOpenConversationId": "x",
-    "srcOpenConvThreadId": "x",
-    "srcOpenConversationId": "x",
-    "srcOpenMessageId": "x"
-  },
-  "product": "im",
-  "tool": "forward_topic"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1043ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-forward-topic --src-msg-id DWSREALTESTNOSUCHID0000000000000 --src-conversation-id cidDWSREALTESTNOSUCHCONV --src-thread-id DWSREALTESTNOSUCHID0000000000000 --dest-conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999428541474e07dd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2127d89817840999428541474e07dd"
-  }
-}
dws chat +messages-list --group <group> --time <time> --forward <forward> --limit <limit>read
拉取群聊会话消息
dws mcp chat <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/list_conversation_message_v2
输入 / CLI 输出
输入
dws chat +messages-list --group x --time x --forward --limit 1 --yes
stdout
{
-  "count": 0,
-  "messages": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "messages": []
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1480ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +messages-list --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openCid or cid is required\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "chat",
-    "trace_id": "2127d89817840997797873841e0757"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openCid or cid is required\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "chat",
-    "trace_id": "2127d89817840997797873841e0757"
-  }
-}
dws chat +messages-list-direct --user <user> --open-dingtalk-id <open-dingtalk-id> --time <time> --forward <forward> --limit <limit>read
拉取单聊会话消息
dws mcp chat <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/list_individual_chat_message
输入 / CLI 输出
输入
dws chat +messages-list-direct --user x --open-dingtalk-id x --time x --forward --limit 1 --yes
stdout
{
-  "count": 0,
-  "messages": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "messages": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1386ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-list-direct --user 103262 --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json
真实 stdout
{
-  "count": 4,
-  "messages": [
-    {
-      "createTime": "2026-07-15 11:02:04",
-      "messageId": "msgmsdyW4xAGL0iXVc9mR4V0g==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "[文档](https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO)\nDWS shortcut 修复复测 20260715:验证 doc +share-doc,请忽略。"
-    },
-    {
-      "createTime": "2026-07-15 11:01:49",
-      "messageId": "msgj2fRqVeyvOLxrZ5EntlMIA==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "DWS shortcut 修复复测 20260715:验证 chat +broadcast,请忽略。"
-    },
-    {
-      "createTime": "2026-07-15 11:01:34",
-      "messageId": "msgEuOor1PmFBNlx9M06N9z1Q==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。"
-    },
-    {
-      "createTime": "2026-07-15 10:12:31",
-      "messageId": "msg4cdzVyqpmXc9WAxnR3lcYQ==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "-1\nDWS shortcut 真实测试 20260715 日程,可忽略\nAsia/Shanghai\ndingtalk://dingtalkclient/action/switchtab?index=1&name=ding&type=calendar&targetDateTime=1784172600000\n1\n1784173500000\n日历使用手册\n0\ndingtalk://dingtalkclient/page/calendar_detail?uniqueId=c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09&corpId=ding8196cd9a2b2405da24f2f5cc6abecb85&from=im_card&redirect_url=https%3A%2F%2Fn.dingtalk.com%2Fdingding%2Fcalendar%2Fdetail%2Findex.html%3Fdd_full_screen%3Dtrue%26dd_progress%3Dfalse%26dd_darkmode%3Dtrue%26%26dd_android_full%3Dtrue%26uniqueId%3Dc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09%26corpId%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26from%3Dim_card\n0\n0\n了解详情\n1784172600000\n1\n439446171\n1784172600000\n1784081621092b363beb2-0536-4131-b9ca-935571fca90d\ndingtalk://dingtalkclient/page/link?url=https%3A%2F%2Falidocs.dingtalk.com%2Fi%2Fp%2FY7kmbokZp3pgGLq2%2Fdocs%2F7Y36k14mK9AV3pKq5zA1J5NqapjblR2D\nfalse\n0\n512653609\n999\n1784173500000\ndingtalk://dingtalkclient/page/calendar_detail?uniqueId=c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09&corpId=ding8196cd9a2b2405da24f2f5cc6abecb85&from=im_card&redirect_url=https%3A%2F%2Fn.dingtalk.com%2Fdingding%2Fcalendar%2Fdetail%2Findex.html%3Fdd_full_screen%3Dtrue%26dd_progress%3Dfalse%26dd_darkmode%3Dtrue%26%26dd_android_full%3Dtrue%26uniqueId%3Dc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09%26corpId%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26from%3Dim_card\nc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 4,
-  "messages": [
-    {
-      "createTime": "2026-07-15 11:02:04",
-      "messageId": "msgmsdyW4xAGL0iXVc9mR4V0g==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "[文档](https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO)\nDWS shortcut 修复复测 20260715:验证 doc +share-doc,请忽略。"
-    },
-    {
-      "createTime": "2026-07-15 11:01:49",
-      "messageId": "msgj2fRqVeyvOLxrZ5EntlMIA==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "DWS shortcut 修复复测 20260715:验证 chat +broadcast,请忽略。"
-    },
-    {
-      "createTime": "2026-07-15 11:01:34",
-      "messageId": "msgEuOor1PmFBNlx9M06N9z1Q==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。"
-    },
-    {
-      "createTime": "2026-07-15 10:12:31",
-      "messageId": "msg4cdzVyqpmXc9WAxnR3lcYQ==",
-      "senderId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-      "text": "-1\nDWS shortcut 真实测试 20260715 日程,可忽略\nAsia/Shanghai\ndingtalk://dingtalkclient/action/switchtab?index=1&name=ding&type=calendar&targetDateTime=1784172600000\n1\n1784173500000\n日历使用手册\n0\ndingtalk://dingtalkclient/page/calendar_detail?uniqueId=c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09&corpId=ding8196cd9a2b2405da24f2f5cc6abecb85&from=im_card&redirect_url=https%3A%2F%2Fn.dingtalk.com%2Fdingding%2Fcalendar%2Fdetail%2Findex.html%3Fdd_full_screen%3Dtrue%26dd_progress%3Dfalse%26dd_darkmode%3Dtrue%26%26dd_android_full%3Dtrue%26uniqueId%3Dc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09%26corpId%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26from%3Dim_card\n0\n0\n了解详情\n1784172600000\n1\n439446171\n1784172600000\n1784081621092b363beb2-0536-4131-b9ca-935571fca90d\ndingtalk://dingtalkclient/page/link?url=https%3A%2F%2Falidocs.dingtalk.com%2Fi%2Fp%2FY7kmbokZp3pgGLq2%2Fdocs%2F7Y36k14mK9AV3pKq5zA1J5NqapjblR2D\nfalse\n0\n512653609\n999\n1784173500000\ndingtalk://dingtalkclient/page/calendar_detail?uniqueId=c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09&corpId=ding8196cd9a2b2405da24f2f5cc6abecb85&from=im_card&redirect_url=https%3A%2F%2Fn.dingtalk.com%2Fdingding%2Fcalendar%2Fdetail%2Findex.html%3Fdd_full_screen%3Dtrue%26dd_progress%3Dfalse%26dd_darkmode%3Dtrue%26%26dd_android_full%3Dtrue%26uniqueId%3Dc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09%26corpId%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26from%3Dim_card\nc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09"
-    }
-  ]
-}
dws chat +messages-list-pin --open-conversation-id <open-conversation-id> --cursor <cursor> --size <size>read
拉取会话中钉住的消息列表
dws mcp im <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_pin_messages
输入 / CLI 输出
输入
dws chat +messages-list-pin --open-conversation-id x --cursor x --size 1 --yes
stdout
{
-  "count": 0,
-  "pins": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "pins": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1105ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-list-pin --open-conversation-id cid3Jijzhe2aqs9ysOXjhi05g== --cursor 0 --size 1 --yes --format json
真实 stdout
{
-  "count": 0,
-  "pins": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "pins": []
-}
dws chat +messages-list-unread-conversations --count <count> --exclude-muted <exclude-muted>read
获取有未读消息的会话列表
dws mcp chat <tool>lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/unread_message_conversation_list
输入 / CLI 输出
输入
dws chat +messages-list-unread-conversations --count 1 --exclude-muted --yes
stdout
{
-  "conversations": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "conversations": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1575ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-list-unread-conversations --count 1 --exclude-muted --yes --format json
真实 stdout
{
-  "conversations": [
-    {
-      "conversationId": "ciddYsoBxGQrFWR5zJ8lnpPBg==",
-      "title": "Wukong & Real 日志群"
-    }
-  ],
-  "count": 1
-}
真实 stderr / error
(空)
真实返回
{
-  "conversations": [
-    {
-      "conversationId": "ciddYsoBxGQrFWR5zJ8lnpPBg==",
-      "title": "Wukong & Real 日志群"
-    }
-  ],
-  "count": 1
-}
dws chat +messages-mget --msg-ids <msg-ids>read
根据消息 ID 批量查询消息(最多 50 条)
dws mcp im list_messages_by_ids --json '{"openMsgIds":<..>}'lark-cli im +messages-mget已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_messages_by_ids
输入 / CLI 输出
输入
dws chat +messages-mget --msg-ids x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openMsgIds": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "list_messages_by_ids"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openMsgIds": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "list_messages_by_ids"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1461ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-mget --msg-ids msgEuOor1PmFBNlx9M06N9z1Q== --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "messages": [
-      {
-        "content": "DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。",
-        "createTime": "2026-07-15 11:01:34",
-        "openConversationId": "cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=",
-        "openMessageId": "msgEuOor1PmFBNlx9M06N9z1Q==",
-        "sender": "念晨",
-        "senderOpenDingTalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I"
-      }
-    ]
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "messages": [
-      {
-        "content": "DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。",
-        "createTime": "2026-07-15 11:01:34",
-        "openConversationId": "cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=",
-        "openMessageId": "msgEuOor1PmFBNlx9M06N9z1Q==",
-        "sender": "念晨",
-        "senderOpenDingTalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I"
-      }
-    ]
-  },
-  "success": true
-}
dws chat +messages-query-send-status --open-task-id <open-task-id>read
查询消息发送状态
dws mcp im query_message_send_status --json '{"openTaskId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/query_message_send_status
输入 / CLI 输出
输入
dws chat +messages-query-send-status --open-task-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openTaskId": "x"
-  },
-  "product": "im",
-  "tool": "query_message_send_status"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openTaskId": "x"
-  },
-  "product": "im",
-  "tool": "query_message_send_status"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1312ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-query-send-status --open-task-id y/wM6Lo+9GbIqtILYPv1BZDcMW+2FgnqskgcpdOiMdM= --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openConversationId": "cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=",
-    "openMessageId": "msgEuOor1PmFBNlx9M06N9z1Q==",
-    "sendStatus": "SUCCESS"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openConversationId": "cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=",
-    "openMessageId": "msgEuOor1PmFBNlx9M06N9z1Q==",
-    "sendStatus": "SUCCESS"
-  },
-  "success": true
-}
dws chat +messages-read-status --conversation-id <conversation-id> --message-id <message-id> --users <users>read
查询消息的已读/未读状态
dws mcp im query_msg_read_status --json '{"openConversationId":<..>, "openMessageId":<..>, "targetUserIds":<..>, "targetOpenDingTalkIds":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/query_msg_read_status
输入 / CLI 输出
输入
dws chat +messages-read-status --conversation-id x --message-id x --users x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x",
-    "targetUserIds": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "query_msg_read_status"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x",
-    "targetUserIds": [
-      "x"
-    ]
-  },
-  "product": "im",
-  "tool": "query_msg_read_status"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1376ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-read-status --conversation-id cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI= --message-id msgEuOor1PmFBNlx9M06N9z1Q== --users '冬翔' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "memberReadStatusList": [
-      {
-        "openDingTalkId": "DAdiiQDqzH1DEqiPOT4eDpd1MUBtiiFg6Xhx",
-        "readStatus": "READ",
-        "readTime": "2026-07-15 11:53:31",
-        "userNick": "冬翔"
-      },
-      {
-        "openDingTalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "readStatus": "READ",
-        "readTime": "2026-07-15 11:01:35",
-        "userNick": "念晨"
-      }
-    ],
-    "messageStatus": "SUCCESS"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "memberReadStatusList": [
-      {
-        "openDingTalkId": "DAdiiQDqzH1DEqiPOT4eDpd1MUBtiiFg6Xhx",
-        "readStatus": "READ",
-        "readTime": "2026-07-15 11:53:31",
-        "userNick": "冬翔"
-      },
-      {
-        "openDingTalkId": "D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I",
-        "readStatus": "READ",
-        "readTime": "2026-07-15 11:01:35",
-        "userNick": "念晨"
-      }
-    ],
-    "messageStatus": "SUCCESS"
-  },
-  "success": true
-}
dws chat +messages-recall --conversation-id <conversation-id> --msg-id <msg-id>write
撤回当前用户发送的消息
dws mcp im recall_message --json '{"openConversationId":<..>, "openMessageId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/recall_message
输入 / CLI 输出
输入
dws chat +messages-recall --conversation-id x --msg-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "recall_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "recall_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1247ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-recall --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999441138103e08c7"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999441138103e08c7"
-  }
-}
dws chat +messages-recall-by-bot --robot-code <robot-code> --group <group> --keys <keys>write
机器人撤回群消息
dws mcp bot recall_robot_group_message --json '{"robotCode":<..>, "openConversationId":<..>, "processQueryKeys":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/recall_robot_group_message
输入 / CLI 输出
输入
dws chat +messages-recall-by-bot --robot-code x --group x --keys x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "processQueryKeys": [
-      "x"
-    ],
-    "robotCode": "x"
-  },
-  "product": "bot",
-  "tool": "recall_robot_group_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "processQueryKeys": [
-      "x"
-    ],
-    "robotCode": "x"
-  },
-  "product": "bot",
-  "tool": "recall_robot_group_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1317ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +messages-recall-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --group cidDWSREALTESTNOSUCHCONV --keys DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。DingOpenResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"dingOpenErrcode\":400012,\"errorMsg\":\"错误描述:无效的openConversationId此参数的示例值如\\\"cidl1B8RVUFmkXXXXXX\\\";解决方案:请检查拼写是否正确;参考链接:请参考本接口对应文档查看参数的获取方式,或者在https://open.dingtalk.com/ 查询如何获取对应的参数\"} ",
-    "trace_id": "2104a64c17840999454382709e08a3"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。DingOpenResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"dingOpenErrcode\":400012,\"errorMsg\":\"错误描述:无效的openConversationId此参数的示例值如\\\"cidl1B8RVUFmkXXXXXX\\\";解决方案:请检查拼写是否正确;参考链接:请参考本接口对应文档查看参数的获取方式,或者在https://open.dingtalk.com/ 查询如何获取对应的参数\"} ",
-    "trace_id": "2104a64c17840999454382709e08a3"
-  }
-}
dws chat +messages-remove-emoji --conversation-id <conversation-id> --msg-id <msg-id> --emoji <emoji>write
移除消息的 emoji 表情回应
dws mcp im remove_emoji_reaction --json '{"openConversationId":<..>, "openMsgId":<..>, "emojiName":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/remove_emoji_reaction
输入 / CLI 输出
输入
dws chat +messages-remove-emoji --conversation-id x --msg-id x --emoji x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "emojiName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x"
-  },
-  "product": "im",
-  "tool": "remove_emoji_reaction"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "emojiName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x"
-  },
-  "product": "im",
-  "tool": "remove_emoji_reaction"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1347ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-remove-emoji --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emoji '👍' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999467733059e08f9"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999467733059e08f9"
-  }
-}
dws chat +messages-remove-text-emotion --conversation-id <conversation-id> --msg-id <msg-id> --emotion-id <emotion-id> --emotion-name <emotion-name> --text <text> --background-id <background-id>write
移除消息的文字表情回应
dws mcp im remove_text_emotion --json '{"openConversationId":<..>, "openMsgId":<..>, "emotionId":<..>, "emotionName":<..>, "text":<..>, "backgroundId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/remove_text_emotion
输入 / CLI 输出
输入
dws chat +messages-remove-text-emotion --conversation-id x --msg-id x --emotion-id x --emotion-name x --text x --background-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "backgroundId": "x",
-    "emotionId": "x",
-    "emotionName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x",
-    "text": "x"
-  },
-  "product": "im",
-  "tool": "remove_text_emotion"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "backgroundId": "x",
-    "emotionId": "x",
-    "emotionName": "x",
-    "openConversationId": "x",
-    "openMsgId": "x",
-    "text": "x"
-  },
-  "product": "im",
-  "tool": "remove_text_emotion"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1210ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-remove-text-emotion --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emotion-id DWSREALTESTNOSUCHID0000000000000 --emotion-name 'DWS测试表情' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --background-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999478923406e087f"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999478923406e087f"
-  }
-}
dws chat +messages-resource-url --type <type> --resource-id <resource-id> --message-id <message-id> --open-conversation-id <open-conversation-id>read
获取消息资源(图片/视频/语音)下载链接
dws mcp im get_resource_download_url --json '{"resourceType":<..>, "resourceId":<..>, "openMessageId":<..>, "openConversationId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/get_resource_download_url
输入 / CLI 输出
输入
dws chat +messages-resource-url --type mediaId --resource-id x --message-id x --open-conversation-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x",
-    "resourceId": "x",
-    "resourceType": "mediaId"
-  },
-  "product": "im",
-  "tool": "get_resource_download_url"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x",
-    "resourceId": "x",
-    "resourceType": "mediaId"
-  },
-  "product": "im",
-  "tool": "get_resource_download_url"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1264ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-resource-url --type mediaId --resource-id x --message-id DWSREALREADNOSUCHID0000000000000 --open-conversation-id cid3Jijzhe2aqs9ysOXjhi05g== --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "RESOURCE_NOT_FOUND",
-    "server_key": "im",
-    "trace_id": "2127d89817840997855423145e07dd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "RESOURCE_NOT_FOUND",
-    "server_key": "im",
-    "trace_id": "2127d89817840997855423145e07dd"
-  }
-}
dws chat +messages-send-by-bot --robot-code <robot-code> --group <group> --title <title> --text <text> --at-user-ids <at-user-ids> --at-open-dingtalk-ids <at-open-dingtalk-ids> --at-all <at-all>write
机器人向群聊发送 Markdown 消息
dws mcp bot send_robot_group_message --json '{"robotCode":<..>, "openConversationId":<..>, "title":<..>, "markdown":<..>, "atUserIds":<..>, "atOpendingtalkIds":<..>, "isAtAll":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/send_robot_group_message
输入 / CLI 输出
输入
dws chat +messages-send-by-bot --robot-code x --group x --title x --text x --at-user-ids x --at-open-dingtalk-ids x --at-all --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "atOpendingtalkIds": [
-      "x"
-    ],
-    "atUserIds": [
-      "x"
-    ],
-    "isAtAll": "true",
-    "markdown": "x",
-    "openConversationId": "x",
-    "robotCode": "x",
-    "title": "x"
-  },
-  "product": "bot",
-  "tool": "send_robot_group_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "atOpendingtalkIds": [
-      "x"
-    ],
-    "atUserIds": [
-      "x"
-    ],
-    "isAtAll": "true",
-    "markdown": "x",
-    "openConversationId": "x",
-    "robotCode": "x",
-    "title": "x"
-  },
-  "product": "bot",
-  "tool": "send_robot_group_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1436ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-send-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --group cidDWSREALTESTNOSUCHCONV --title 'DWS shortcut 真实测试 20260715-151724' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --at-user-ids __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --at-open-dingtalk-ids '' --at-all --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "BUSINESS_ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。DingOpenResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"400004\",\"errorMsg\":\"robotCode对应的机器人模板不存在\"} ",
-    "trace_id": "0b5deb3217840999494005921e08ef"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "BUSINESS_ERROR",
-    "server_key": "bot",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。DingOpenResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"400004\",\"errorMsg\":\"robotCode对应的机器人模板不存在\"} ",
-    "trace_id": "0b5deb3217840999494005921e08ef"
-  }
-}
dws chat +messages-send-by-webhook --token <token> --title <title> --text <text> --at-all <at-all> --at-mobiles <at-mobiles> --at-users <at-users>write
自定义机器人 Webhook 发送群消息
dws mcp bot send_message_by_custom_robot --json '{"robotToken":<..>, "title":<..>, "text":<..>, "isAtAll":<..>, "atMobiles":<..>, "atUserIds":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;bot/send_message_by_custom_robot
输入 / CLI 输出
输入
dws chat +messages-send-by-webhook --token x --title x --text x --at-all --at-mobiles x --at-users x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "atMobiles": [
-      "x"
-    ],
-    "atUserIds": [
-      "x"
-    ],
-    "isAtAll": true,
-    "robotToken": "x",
-    "text": "x",
-    "title": "x"
-  },
-  "product": "bot",
-  "tool": "send_message_by_custom_robot"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "atMobiles": [
-      "x"
-    ],
-    "atUserIds": [
-      "x"
-    ],
-    "isAtAll": true,
-    "robotToken": "x",
-    "text": "x",
-    "title": "x"
-  },
-  "product": "bot",
-  "tool": "send_message_by_custom_robot"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=925ms;real-backend-safe-negative-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-send-by-webhook --token __DWS_SHORTCUT_REAL_TEST_NO_SUCH_WEBHOOK_TOKEN__ --title 'DWS shortcut 真实测试 20260715-104025' --text 'DWS shortcut 真实测试 20260715-104025,安全负向输入,请忽略。' --at-all --at-mobiles x --at-users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json
真实 stdout
{
-  "errcode": "300005",
-  "errmsg": "token is not exist",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "errcode": "300005",
-  "errmsg": "token is not exist",
-  "success": true
-}
dws chat +messages-send-card --group <group> --receiver <receiver>write
创建并推送流式卡片(需配合 messages-update-card)
dws mcp im create_and_send_card --json '{"openConversationId":<..>, "receiverUid":<..>}'lark im 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--group 与 --receiver 互斥
输入 / CLI 输出
输入
dws chat +messages-send-card --group x --receiver x --yes
stdout
(空)
stderr / error
--group 与 --receiver 互斥
最终用于判定的返回
--group 与 --receiver 互斥
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1502ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-dry-run 已证明 CLI 装配了 receiverUid;真实后端仍报 receiverUid/openConversationId 为空,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +messages-send-card --receiver 103262 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999509255753e081a"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999509255753e081a"
-  }
-}
dws chat +messages-set-pin --open-conversation-id <open-conversation-id> --msg-id <msg-id>write
钉住消息(Pin)
dws mcp im set_pin_message --json '{"openConversationId":<..>, "cid":<..>, "openMessageId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/set_pin_message
输入 / CLI 输出
输入
dws chat +messages-set-pin --open-conversation-id x --msg-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "set_pin_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "set_pin_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1217ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +messages-set-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999521117991e0758"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "0bb7c36217840999521117991e0758"
-  }
-}
dws chat +messages-set-top --open-conversation-id <open-conversation-id> --msg-id <msg-id>write
置顶消息
dws mcp im set_top_message --json '{"openConversationId":<..>, "openMessageId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/set_top_message
输入 / CLI 输出
输入
dws chat +messages-set-top --open-conversation-id x --msg-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "set_top_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "set_top_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1121ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-set-top --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999532438476e0817"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999532438476e0817"
-  }
-}
dws chat +messages-unset-pin --open-conversation-id <open-conversation-id> --msg-id <msg-id>write
取消钉住消息(Unpin)
dws mcp im unset_pin_message --json '{"openConversationId":<..>, "cid":<..>, "openMessageId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/unset_pin_message
输入 / CLI 输出
输入
dws chat +messages-unset-pin --open-conversation-id x --msg-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "unset_pin_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cid": "x",
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "unset_pin_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1185ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。
真实输入
/private/tmp/dws-real-test chat +messages-unset-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999544428103e08ee"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "im",
-    "trace_id": "2104a64c17840999544428103e08ee"
-  }
-}
dws chat +messages-unset-top --open-conversation-id <open-conversation-id> --msg-id <msg-id>write
取消置顶消息
dws mcp im unset_top_message --json '{"openConversationId":<..>, "openMessageId":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/unset_top_message
输入 / CLI 输出
输入
dws chat +messages-unset-top --open-conversation-id x --msg-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "unset_top_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x"
-  },
-  "product": "im",
-  "tool": "unset_top_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=963ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +messages-unset-top --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999554154221e08f9"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999554154221e08f9"
-  }
-}
dws chat +messages-update-card --biz-id <biz-id> --content <content> --flow-status <flow-status>write
流式更新卡片内容(最后一次 --flow-status 应为 3)
dws mcp im update_streaming_card --json '{"bizId":<..>, "msgContent":<..>, "flowStatus":<..>}'lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/update_streaming_card
输入 / CLI 输出
输入
dws chat +messages-update-card --biz-id x --content x --flow-status 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "bizId": "x",
-    "flowStatus": 1,
-    "msgContent": "x"
-  },
-  "product": "im",
-  "tool": "update_streaming_card"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "bizId": "x",
-    "flowStatus": 1,
-    "msgContent": "x"
-  },
-  "product": "im",
-  "tool": "update_streaming_card"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=934ms;real-backend-safe-negative-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +messages-update-card --biz-id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__ --content 'DWS shortcut 真实测试 20260715-104025,安全负向输入,请忽略。' --flow-status 1 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws chat +my-groups --type <type>read复合/智能型
列出我加入的群,可按类型过滤并投影关键字段
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_my_groups_pagination
输入 / CLI 输出
输入
dws chat +my-groups --type x --yes
stdout
{
-  "count": 0,
-  "groups": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "groups": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1499ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +my-groups --type ALL --yes --format json
真实 stdout
{
-  "count": 0,
-  "groups": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "groups": []
-}
dws chat +search-msg --group <group> --query <query> --days <days>read复合/智能型
在群内按关键词搜消息(默认近 7 天,投影发送人/时间/内容/messageId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/search_messages_by_keyword
输入 / CLI 输出
输入
dws chat +search-msg --group x --query x --days 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cursor": "0",
-    "endTime": 1784105093141,
-    "keyword": "x",
-    "limit": 100,
-    "openConversationId": "x",
-    "startTime": 1784018693141
-  },
-  "product": "chat",
-  "tool": "search_messages_by_keyword"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cursor": "0",
-    "endTime": 1784105093141,
-    "keyword": "x",
-    "limit": 100,
-    "openConversationId": "x",
-    "startTime": 1784018693141
-  },
-  "product": "chat",
-  "tool": "search_messages_by_keyword"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1519ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test chat +search-msg --group cid3Jijzhe2aqs9ysOXjhi05g== --query '测试' --days 7 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "SearchRightsDenied",
-    "server_key": "chat",
-    "trace_id": "0b5deb3217840997866824643e0853"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "SearchRightsDenied",
-    "server_key": "chat",
-    "trace_id": "0b5deb3217840997866824643e0853"
-  }
-}
dws chat +send-to-group --group <group> --text <text>write复合/智能型
按群名直接给群发消息(自动搜群解析 openConversationId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 send_personal_messagelark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/search_groups;没找到名字匹配 "x" 的群;换用群名里连续的核心词再试。
输入 / CLI 输出
输入
dws chat +send-to-group --group x --text x --yes
stdout
(空)
stderr / error
没找到名字匹配 "x" 的群;换用群名里连续的核心词再试。
最终用于判定的返回
没找到名字匹配 "x" 的群;换用群名里连续的核心词再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1854ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +send-to-group --group 'DWS测0715' --text 'DWS shortcut 真实测试 20260715:验证 chat +send-to-group,请忽略。' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openTaskId": "tECwJpPL2sAnZwUZhjTFhNWleCgSp0GSMPlGkMUIL5Q="
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openTaskId": "tECwJpPL2sAnZwUZhjTFhNWleCgSp0GSMPlGkMUIL5Q="
-  },
-  "success": true
-}
dws chat +thread-replies --group <group> --topic-id <topic-id> --time <time> --limit <limit>read复合/智能型
拉取某条话题消息的全部回复并投影出发言人/文本/时间
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/list_topic_replies
输入 / CLI 输出
输入
dws chat +thread-replies --group x --topic-id x --time x --limit 1 --yes
stdout
{
-  "count": 0,
-  "replies": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "replies": []
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1582ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test chat +thread-replies --group cid3Jijzhe2aqs9ysOXjhi05g== --topic-id x --time '2026-07-15 10:00:00' --limit 10 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] failed to decrypt openConvThreadId\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "chat",
-    "trace_id": "0b5deb3217840997883504915e0853"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] failed to decrypt openConvThreadId\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "1001",
-    "server_key": "chat",
-    "trace_id": "0b5deb3217840997883504915e0853"
-  }
-}
dws chat +unread-chats --count <count> --exclude-muted <exclude-muted>read复合/智能型
列出我有未读消息的会话(投影会话名/未读数/会话ID)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark im 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;chat/unread_message_conversation_list
输入 / CLI 输出
输入
dws chat +unread-chats --count 1 --exclude-muted --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "count": 1,
-    "excludeMuted": true
-  },
-  "product": "chat",
-  "tool": "unread_message_conversation_list"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "count": 1,
-    "excludeMuted": true
-  },
-  "product": "chat",
-  "tool": "unread_message_conversation_list"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1649ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test chat +unread-chats --count 1 --exclude-muted --yes --format json
真实 stdout
{
-  "conversations": [
-    {
-      "conversationId": "ciddYsoBxGQrFWR5zJ8lnpPBg==",
-      "name": "Wukong & Real 日志群"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "conversations": [
-    {
-      "conversationId": "ciddYsoBxGQrFWR5zJ8lnpPBg==",
-      "name": "Wukong & Real 日志群"
-    }
-  ]
-}
-

aitable · 82 个 · ↔ lark base

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws aitable +advperm-disable --base-id <base-id>high-risk-write
关闭指定 Base 的高级权限总开关(所有自定义角色失效)
dws mcp aitable set_advanced_permission --json '{"baseId":<..>, "enabled":<..>}'lark-cli base +advperm-disable已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/set_advanced_permission
输入 / CLI 输出
输入
dws aitable +advperm-disable --base-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "enabled": false
-  },
-  "product": "aitable-helper",
-  "tool": "set_advanced_permission"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "enabled": false
-  },
-  "product": "aitable-helper",
-  "tool": "set_advanced_permission"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1447ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test aitable +advperm-disable --base-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set advanced permission for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998455921653e08de\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set advanced permission for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998455921653e08de\"}"
-  }
-}
dws aitable +advperm-enable --base-id <base-id>write
开启指定 Base 的高级权限总开关
dws mcp aitable set_advanced_permission --json '{"baseId":<..>, "enabled":<..>}'lark-cli base +advperm-enable已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/set_advanced_permission
输入 / CLI 输出
输入
dws aitable +advperm-enable --base-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "enabled": true
-  },
-  "product": "aitable-helper",
-  "tool": "set_advanced_permission"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "enabled": true
-  },
-  "product": "aitable-helper",
-  "tool": "set_advanced_permission"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1358ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test aitable +advperm-enable --base-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set advanced permission for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998469916877e08c8\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set advanced permission for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998469916877e08c8\"}"
-  }
-}
dws aitable +attachment-upload --base-id <base-id> --file-name <file-name> --size <size> --mime-type <mime-type>write
为 attachment 字段申请 OSS 直传地址(uploadUrl / fileToken)
dws mcp aitable prepare_attachment_upload --json '{"baseId":<..>, "fileName":<..>, "size":<..>, "mimeType":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/prepare_attachment_upload
输入 / CLI 输出
输入
dws aitable +attachment-upload --base-id x --file-name x --size 1 --mime-type x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fileName": "x",
-    "mimeType": "x",
-    "size": 1
-  },
-  "product": "aitable",
-  "tool": "prepare_attachment_upload"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fileName": "x",
-    "mimeType": "x",
-    "size": 1
-  },
-  "product": "aitable",
-  "tool": "prepare_attachment_upload"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1131ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +attachment-upload --base-id DWSREALTESTNOSUCHID0000000000000 --file-name dws-shortcut-real-20260715-151724.txt --size 1 --mime-type text/plain --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Specified base does not exist, has been deleted, or is inaccessible\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to prepare attachment upload: base does not exist or is inaccessible\",\"trace_id\":\"213ee25c17840998481228948e087f\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Specified base does not exist, has been deleted, or is inaccessible\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to prepare attachment upload: base does not exist or is inaccessible\",\"trace_id\":\"213ee25c17840998481228948e087f\"}"
-  }
-}
dws aitable +base-copy --base-id <base-id> --target-folder-id <target-folder-id> --only-struct <only-struct>write
复制 AI 表格到指定目录(可仅复制结构)
dws mcp aitable copy_base --json '{"baseId":<..>, "targetFolderId":<..>, "onlyCopyMeta":<..>}'lark-cli base +base-copy已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/copy_base
输入 / CLI 输出
输入
dws aitable +base-copy --base-id x --target-folder-id x --only-struct --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "onlyCopyMeta": true,
-    "targetFolderId": "x"
-  },
-  "product": "aitable",
-  "tool": "copy_base"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "onlyCopyMeta": true,
-    "targetFolderId": "x"
-  },
-  "product": "aitable",
-  "tool": "copy_base"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1318ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +base-copy --base-id DWSREALTESTNOSUCHID0000000000000 --target-folder-id x --only-struct --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":null,\"message\":\"Invalid source baseId: DWSREALTESTNOSUCHID0000000000000\",\"retryable\":null,\"type\":\"USER_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Invalid source baseId\",\"trace_id\":\"213ee25c17840998493715617e08e2\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":null,\"message\":\"Invalid source baseId: DWSREALTESTNOSUCHID0000000000000\",\"retryable\":null,\"type\":\"USER_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Invalid source baseId\",\"trace_id\":\"213ee25c17840998493715617e08e2\"}"
-  }
-}
dws aitable +base-delete --base-id <base-id> --reason <reason>high-risk-write
删除指定 Base(不可逆)
dws mcp aitable delete_base --json '{"baseId":<..>, "reason":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/delete_base
输入 / CLI 输出
输入
dws aitable +base-delete --base-id x --reason x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "reason": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_base"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "reason": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_base"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1124ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test aitable +base-delete --base-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"52600003\",\"message\":\"Data not found\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete base DWSREALTESTNOSUCHID0000000000000. Check whether the base still exists, the caller still has access, and the baseId is from get_base/search_bases before retrying.\",\"trace_id\":\"2132f5ca17840998505858640e0874\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"52600003\",\"message\":\"Data not found\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete base DWSREALTESTNOSUCHID0000000000000. Check whether the base still exists, the caller still has access, and the baseId is from get_base/search_bases before retrying.\",\"trace_id\":\"2132f5ca17840998505858640e0874\"}"
-  }
-}
dws aitable +base-get --base-id <base-id>read
获取指定 Base 的目录信息(tables / dashboards summary)
dws mcp aitable get_base --json '{"baseId":<..>}'lark-cli base +base-get已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_base
输入 / CLI 输出
输入
dws aitable +base-get --base-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_base"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_base"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1773ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +base-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json
真实 stdout
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "baseName": "Real共创版设备去向登记",
-    "dashboards": [
-      {
-        "chartCount": 8,
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2",
-        "dashboardName": "仪表盘",
-        "meta": {}
-      }
-    ],
-    "documents": [],
-    "tables": [
-      {
-        "tableId": "hERWDMS",
-        "tableName": "Mac Mini"
-      },
-      {
-        "tableId": "k8d898q",
-        "tableName": "手机"
-      },
-      {
-        "tableId": "zyLRwwa",
-        "tableName": "SIM卡"
-      },
-      {
-        "tableId": "L53jNZz",
-        "tableName": "机箱"
-      },
-      {
-        "tableId": "zTiHNDL",
-        "tableName": "磁吸片"
-      },
-      {
-        "tableId": "anMdihs",
-        "tableName": "手机线"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got base 'Real共创版设备去向登记' with 6 table(s). URL rules: AI 表格访问地址: https://docs.dingtalk.com/i/nodes/{baseId}; AI 表格数据表访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}; AI 表格仪表盘访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{dashboardId}; AI 表格说明文档地址: https://pre-alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{documentId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "baseName": "Real共创版设备去向登记",
-    "dashboards": [
-      {
-        "chartCount": 8,
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2",
-        "dashboardName": "仪表盘",
-        "meta": {}
-      }
-    ],
-    "documents": [],
-    "tables": [
-      {
-        "tableId": "hERWDMS",
-        "tableName": "Mac Mini"
-      },
-      {
-        "tableId": "k8d898q",
-        "tableName": "手机"
-      },
-      {
-        "tableId": "zyLRwwa",
-        "tableName": "SIM卡"
-      },
-      {
-        "tableId": "L53jNZz",
-        "tableName": "机箱"
-      },
-      {
-        "tableId": "zTiHNDL",
-        "tableName": "磁吸片"
-      },
-      {
-        "tableId": "anMdihs",
-        "tableName": "手机线"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got base 'Real共创版设备去向登记' with 6 table(s). URL rules: AI 表格访问地址: https://docs.dingtalk.com/i/nodes/{baseId}; AI 表格数据表访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}; AI 表格仪表盘访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{dashboardId}; AI 表格说明文档地址: https://pre-alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{documentId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。"
-}
dws aitable +base-get-primary-doc-id --base-id <base-id> --table-id <table-id> --record-id <record-id>read
根据 baseId/tableId/recordId 获取主键文档的 dentryUuid
dws mcp aitable get_base_primary_doc_id --json '{"baseId":<..>, "tableId":<..>, "recordId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_base_primary_doc_id
输入 / CLI 输出
输入
dws aitable +base-get-primary-doc-id --base-id x --table-id x --record-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_base_primary_doc_id"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_base_primary_doc_id"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1929ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +base-get-primary-doc-id --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"-1\",\"message\":\"no record\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to query cell doc for record 1015oH3OXy in table hERWDMS\",\"trace_id\":\"0b5deb3217840997466255627e08ee\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"-1\",\"message\":\"no record\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to query cell doc for record 1015oH3OXy in table hERWDMS\",\"trace_id\":\"0b5deb3217840997466255627e08ee\"}"
-  }
-}
dws aitable +base-list --limit <limit> --cursor <cursor>read
获取当前用户可访问的 AI 表格 Base 列表(最近访问,支持游标分页)
dws mcp aitable <tool>lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/list_bases
输入 / CLI 输出
输入
dws aitable +base-list --limit 1 --cursor x --yes
stdout
{
-  "bases": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "bases": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1289ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +base-list --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "bases": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "bases": [],
-  "count": 0
-}
dws aitable +base-search --query <query> --cursor <cursor>read
按名称关键词搜索 AI 表格 Base
dws mcp aitable <tool>lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/search_bases
输入 / CLI 输出
输入
dws aitable +base-search --query x --cursor x --yes
stdout
{
-  "bases": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "bases": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1628ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +base-search --query '测试' --cursor 0 --yes --format json
真实 stdout
{
-  "bases": [
-    {
-      "baseId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03"
-    },
-    {
-      "baseId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "baseName": "文件扫描测试 -20260414"
-    },
-    {
-      "baseId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "baseName": "文件扫描测试(25)"
-    },
-    {
-      "baseId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "baseName": "文件扫描测试(5)"
-    },
-    {
-      "baseId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "baseName": "文件扫描测试(24)"
-    },
-    {
-      "baseId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "baseName": "文件扫描测试(22)"
-    },
-    {
-      "baseId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "baseName": "文件扫描测试(26)(2)"
-    },
-    {
-      "baseId": "MyQA2dXW7oOA63Yac5lMpzxkWzlwrZgb",
-      "baseName": "文件扫描测试"
-    },
-    {
-      "baseId": "9E05BDRVQePjzLkZtyX1L6vmV63zgkYA",
-      "baseName": "工作目录文件扫描测试"
-    },
-    {
-      "baseId": "y20BglGWOGQgpOj6u0GaqL3EWA7depqY",
-      "baseName": "case-0055-数据导入测试"
-    },
-    {
-      "baseId": "14dA3GK8gkP4oNl0cKqRz7jy89ekBD76",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-22 19:41:26"
-    },
-    {
-      "baseId": "gpG2NdyVXQyZ0OmoSGLpk7EdJMwvDqPk",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-22 18:41:20"
-    },
-    {
-      "baseId": "7dx2rn0Jbakrq9O5t2GbL30XVMGjLRb3",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-22 16:33:50"
-    },
-    {
-      "baseId": "OG9lyrgJPL7M2ONQSzPgGxwvJzN67Mw4",
-      "baseName": "case-0052-临时测试"
-    },
-    {
-      "baseId": "wva2dxOW4kO6dpRac0jppNE58bkz3BRL",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-21 22:11:30"
-    },
-    {
-      "baseId": "0eMKjyp81MrAdNmPu4NvYXAE8xAZB1Gv",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 21:24:44"
-    },
-    {
-      "baseId": "dxXB52LJqwZrolvyFMkR2MDPWqjMp697",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 16:53:48"
-    },
-    {
-      "baseId": "bva6QBXJwZenolNyS2Gwg2NxVn4qY5Pr",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 15:53:56"
-    },
-    {
-      "baseId": "bva6QBXJwZenolNyS2G3bNnOVn4qY5Pr",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 09:09:21"
-    },
-    {
-      "baseId": "pYLaezmVNGmDXOMxuGjwevPqJrMqPxX6",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 08:09:11"
-    }
-  ],
-  "count": 20
-}
真实 stderr / error
(空)
真实返回
{
-  "bases": [
-    {
-      "baseId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03"
-    },
-    {
-      "baseId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "baseName": "文件扫描测试 -20260414"
-    },
-    {
-      "baseId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "baseName": "文件扫描测试(25)"
-    },
-    {
-      "baseId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "baseName": "文件扫描测试(5)"
-    },
-    {
-      "baseId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "baseName": "文件扫描测试(24)"
-    },
-    {
-      "baseId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "baseName": "文件扫描测试(22)"
-    },
-    {
-      "baseId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "baseName": "文件扫描测试(26)(2)"
-    },
-    {
-      "baseId": "MyQA2dXW7oOA63Yac5lMpzxkWzlwrZgb",
-      "baseName": "文件扫描测试"
-    },
-    {
-      "baseId": "9E05BDRVQePjzLkZtyX1L6vmV63zgkYA",
-      "baseName": "工作目录文件扫描测试"
-    },
-    {
-      "baseId": "y20BglGWOGQgpOj6u0GaqL3EWA7depqY",
-      "baseName": "case-0055-数据导入测试"
-    },
-    {
-      "baseId": "14dA3GK8gkP4oNl0cKqRz7jy89ekBD76",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-22 19:41:26"
-    },
-    {
-      "baseId": "gpG2NdyVXQyZ0OmoSGLpk7EdJMwvDqPk",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-22 18:41:20"
-    },
-    {
-      "baseId": "7dx2rn0Jbakrq9O5t2GbL30XVMGjLRb3",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-22 16:33:50"
-    },
-    {
-      "baseId": "OG9lyrgJPL7M2ONQSzPgGxwvJzN67Mw4",
-      "baseName": "case-0052-临时测试"
-    },
-    {
-      "baseId": "wva2dxOW4kO6dpRac0jppNE58bkz3BRL",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-21 22:11:30"
-    },
-    {
-      "baseId": "0eMKjyp81MrAdNmPu4NvYXAE8xAZB1Gv",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 21:24:44"
-    },
-    {
-      "baseId": "dxXB52LJqwZrolvyFMkR2MDPWqjMp697",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 16:53:48"
-    },
-    {
-      "baseId": "bva6QBXJwZenolNyS2Gwg2NxVn4qY5Pr",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 15:53:56"
-    },
-    {
-      "baseId": "bva6QBXJwZenolNyS2G3bNnOVn4qY5Pr",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 09:09:21"
-    },
-    {
-      "baseId": "pYLaezmVNGmDXOMxuGjwevPqJrMqPxX6",
-      "baseName": "🧪测试 工作区目录扫描 - 2026-04-20 08:09:11"
-    }
-  ],
-  "count": 20
-}
dws aitable +base-update --base-id <base-id> --name <name> --desc <desc>write
更新 Base 名称(可选备注)
dws mcp aitable update_base --json '{"baseId":<..>, "newBaseName":<..>, "description":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/update_base
输入 / CLI 输出
输入
dws aitable +base-update --base-id x --name x --desc x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "description": "x",
-    "newBaseName": "x"
-  },
-  "product": "aitable",
-  "tool": "update_base"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "description": "x",
-    "newBaseName": "x"
-  },
-  "product": "aitable",
-  "tool": "update_base"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1318ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +base-update --base-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --desc 'DWS shortcut 真实测试描述,可删除' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Specified base does not exist, has been deleted, or is inaccessible\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update base because base does not exist or is inaccessible\",\"trace_id\":\"2132f5ca17840998518767946e08b6\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Specified base does not exist, has been deleted, or is inaccessible\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update base because base does not exist or is inaccessible\",\"trace_id\":\"2132f5ca17840998518767946e08b6\"}"
-  }
-}
dws aitable +chart-delete --base-id <base-id> --dashboard-id <dashboard-id> --chart-id <chart-id> --reason <reason>high-risk-write
删除指定 chart 及其布局项(不可逆)
dws mcp aitable delete_chart --json '{"baseId":<..>, "dashboardId":<..>, "chartId":<..>, "reason":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/delete_chart
输入 / CLI 输出
输入
dws aitable +chart-delete --base-id x --dashboard-id x --chart-id x --reason x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x",
-    "reason": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_chart"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x",
-    "reason": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_chart"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1154ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +chart-delete --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete chart DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998530143696e08c9\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete chart DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998530143696e08c9\"}"
-  }
-}
dws aitable +chart-get --base-id <base-id> --dashboard-id <dashboard-id> --chart-id <chart-id>read
获取指定 chart 的详细信息
dws mcp aitable get_chart --json '{"baseId":<..>, "dashboardId":<..>, "chartId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_chart
输入 / CLI 输出
输入
dws aitable +chart-get --base-id x --dashboard-id x --chart-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_chart"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_chart"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1038ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +chart-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --chart-id widget-dlxFo0tNSImp4ITpDn5fQ --yes --format json
真实 stdout
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "chartId": "widget-dlxFo0tNSImp4ITpDn5fQ",
-    "chartName": "各业务线未部署数",
-    "chartType": "BAR",
-    "config": {
-      "axisLabelShow": false,
-      "categoryAxisMaxRatio": "0.3",
-      "chartType": "BAR",
-      "colors": "COLOR_PALETTE_1",
-      "dimension": [
-        {
-          "value": "rjwzzIu"
-        }
-      ],
-      "fillType": "solid",
-      "filter": [],
-      "gridShow": false,
-      "group": [
-        {
-          "value": "none"
-        }
-      ],
-      "label": true,
-      "labelHideOverlap": true,
-      "legend": "top",
-      "measure": [
-        {
-          "externalValue": [
-            {
-              "type": "formula",
-              "value": "count"
-            }
-          ],
-          "value": "kFwb8br"
-        }
-      ],
-      "measureType": "field",
-      "nullValue": "ignore",
-      "sheet": "hERWDMS",
-      "sortRule": "asc",
-      "view": "",
-      "xAbbr": {
-        "abbr": "",
-        "decimal": "",
-        "numberType": "default"
-      },
-      "xAxisLabel": true,
-      "xAxisLabelRotate": "auto",
-      "xAxisLine": false,
-      "xAxisShow": true,
-      "xSortBy": "x",
-      "xSortCustom": [],
-      "yAbbr": {
-        "abbr": "",
-        "decimal": "",
-        "numberType": "default"
-      },
-      "yAxisLabel": true,
-      "yAxisLine": false,
-      "yAxisRange": {
-        "range": [],
-        "type": "absolute"
-      },
-      "yAxisShow": true,
-      "yRightAbbr": {
-        "abbr": "",
-        "decimal": "",
-        "numberType": "default"
-      },
-      "ySortCustom": []
-    },
-    "dashboardId": "KY9tlWg5NEHgfs8WT6IO2",
-    "layout": {
-      "h": 5,
-      "i": "widget-dlxFo0tNSImp4ITpDn5fQ",
-      "w": 6,
-      "x": 0,
-      "y": 4
-    }
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got chart widget-dlxFo0tNSImp4ITpDn5fQ"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "chartId": "widget-dlxFo0tNSImp4ITpDn5fQ",
-    "chartName": "各业务线未部署数",
-    "chartType": "BAR",
-    "config": {
-      "axisLabelShow": false,
-      "categoryAxisMaxRatio": "0.3",
-      "chartType": "BAR",
-      "colors": "COLOR_PALETTE_1",
-      "dimension": [
-        {
-          "value": "rjwzzIu"
-        }
-      ],
-      "fillType": "solid",
-      "filter": [],
-      "gridShow": false,
-      "group": [
-        {
-          "value": "none"
-        }
-      ],
-      "label": true,
-      "labelHideOverlap": true,
-      "legend": "top",
-      "measure": [
-        {
-          "externalValue": [
-            {
-              "type": "formula",
-              "value": "count"
-            }
-          ],
-          "value": "kFwb8br"
-        }
-      ],
-      "measureType": "field",
-      "nullValue": "ignore",
-      "sheet": "hERWDMS",
-      "sortRule": "asc",
-      "view": "",
-      "xAbbr": {
-        "abbr": "",
-        "decimal": "",
-        "numberType": "default"
-      },
-      "xAxisLabel": true,
-      "xAxisLabelRotate": "auto",
-      "xAxisLine": false,
-      "xAxisShow": true,
-      "xSortBy": "x",
-      "xSortCustom": [],
-      "yAbbr": {
-        "abbr": "",
-        "decimal": "",
-        "numberType": "default"
-      },
-      "yAxisLabel": true,
-      "yAxisLine": false,
-      "yAxisRange": {
-        "range": [],
-        "type": "absolute"
-      },
-      "yAxisShow": true,
-      "yRightAbbr": {
-        "abbr": "",
-        "decimal": "",
-        "numberType": "default"
-      },
-      "ySortCustom": []
-    },
-    "dashboardId": "KY9tlWg5NEHgfs8WT6IO2",
-    "layout": {
-      "h": 5,
-      "i": "widget-dlxFo0tNSImp4ITpDn5fQ",
-      "w": 6,
-      "x": 0,
-      "y": 4
-    }
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got chart widget-dlxFo0tNSImp4ITpDn5fQ"
-}
dws aitable +chart-share-get --base-id <base-id> --dashboard-id <dashboard-id> --chart-id <chart-id>read
查询 chart 的分享配置
dws mcp aitable get_chart_share --json '{"baseId":<..>, "dashboardId":<..>, "chartId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_chart_share
输入 / CLI 输出
输入
dws aitable +chart-share-get --base-id x --dashboard-id x --chart-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_chart_share"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_chart_share"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1584ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test aitable +chart-share-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --chart-id widget-dlxFo0tNSImp4ITpDn5fQ --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"403\",\"message\":\"Forbidden\",\"retryable\":false,\"type\":\"AUTH_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get chart share config for chart widget-dlxFo0tNSImp4ITpDn5fQ: [403] Forbidden\",\"trace_id\":\"2127d89817840997481396653e075d\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"403\",\"message\":\"Forbidden\",\"retryable\":false,\"type\":\"AUTH_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get chart share config for chart widget-dlxFo0tNSImp4ITpDn5fQ: [403] Forbidden\",\"trace_id\":\"2127d89817840997481396653e075d\"}"
-  }
-}
dws aitable +chart-share-update --base-id <base-id> --dashboard-id <dashboard-id> --chart-id <chart-id> --enabled <enabled> --share-type <share-type> --allow-back-to-doc <allow-back-to-doc>write
开启/关闭 chart 分享并可设置分享类型
dws mcp aitable update_chart_share --json '{"baseId":<..>, "dashboardId":<..>, "chartId":<..>, "enabled":<..>, "shareType":<..>, "allowBackToDoc":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/update_chart_share
输入 / CLI 输出
输入
dws aitable +chart-share-update --base-id x --dashboard-id x --chart-id x --enabled --share-type PUBLIC --allow-back-to-doc --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "allowBackToDoc": true,
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x",
-    "enabled": true,
-    "shareType": "PUBLIC"
-  },
-  "product": "aitable",
-  "tool": "update_chart_share"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "allowBackToDoc": true,
-    "baseId": "x",
-    "chartId": "x",
-    "dashboardId": "x",
-    "enabled": true,
-    "shareType": "PUBLIC"
-  },
-  "product": "aitable",
-  "tool": "update_chart_share"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1175ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +chart-share-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --enabled --share-type PUBLIC --allow-back-to-doc --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update chart share for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998541842968e094d\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update chart share for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998541842968e094d\"}"
-  }
-}
dws aitable +chart-update --base-id <base-id> --dashboard-id <dashboard-id> --chart-id <chart-id> --config <config> --layout <layout>write
更新指定 chart 的配置或布局(--config 必填)
dws mcp aitable update_chart --json '{"baseId":<..>, "dashboardId":<..>, "chartId":<..>, "config":<..>, "layout":<..>}'lark base 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--config JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +chart-update --base-id x --dashboard-id x --chart-id x --config x --layout x --yes
stdout
(空)
stderr / error
--config JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--config JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1102ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +chart-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --layout '{}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"config is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update chart because config is missing\",\"trace_id\":\"2106d98117840998553244877e08df\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"config is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update chart because config is missing\",\"trace_id\":\"2106d98117840998553244877e08df\"}"
-  }
-}
dws aitable +chart-widgets-exampleread
获取所有图表类型的 widget config 示例
dws mcp aitable get_dashboard_widgets_examplelark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_dashboard_widgets_example
输入 / CLI 输出
输入
dws aitable +chart-widgets-example --yes
stdout
{
-  "_fake": true,
-  "args": {},
-  "product": "aitable",
-  "tool": "get_dashboard_widgets_example"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {},
-  "product": "aitable",
-  "tool": "get_dashboard_widgets_example"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1286ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +chart-widgets-example --yes --format json
真实 stdout
{
-  "data": "// 注意\n// 1. 下述 JSON 示例中的注释(// 开头的注释)仅用于说明,实际使用时请删除,否则会导致解析错误\n// 2. 所有图表类型的 widget config 示例,key 是图表类型,value 是对应的完整 config\n// 3. 创建图表时,必需使用示例中的图表类型和配置格式\n\n\n{\n// 聚合列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"AGGREGATE_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"AGGREGATE_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"聚合列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置聚合列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置聚合列表的显示方式和布局\n    \"listConfig\": {},\n    // 字段聚合映射,用于设置字段的聚合方式(如 sum、count、avg 等)\n    \"fieldAggregateMap\": {},\n    // 工具栏配置,用于控制聚合列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// AI 分析卡片配置格式,必须按照此格式配置,否则会导致解析错误\n  // 注意:AI 分析卡片不依赖数据表,无需配置 sheet / view / filter\n  \"AI_ANALYZE\": {\n    // 必填,图表类型,固定值 \"AI_ANALYZE\"\n    \"chartType\": \"AI_ANALYZE\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"AI 分析卡片\",\n    // 可选,AI 提示词,用自然语言描述希望 AI 分析的内容和问题,\n    // 例如:\"分析本月各产品线的销售额趋势,找出增长最快的类别并给出建议\"\n    \"prompt\": \"\",\n    // 可选,需要 AI 参考分析的图表 ID 列表,数组中每个元素必须是当前仪表盘中已存在的图表 ID;\n    // 不填或传空数组时 AI 将基于提示词独立分析,不关联具体图表数据\n    \"analyzeChartIds\": [],\n    // 可选,指定 AI 模型 ID;不填时使用系统默认模型\n    \"modelId\": \"\",\n    // 只读,AI 生成的 Markdown 分析内容,由系统写入,创建时无需填写\n    \"content\": \"\",\n    // 只读,AI 分析结果的最后更新时间戳(毫秒),由系统写入,创建时无需填写\n    \"lastUpdatedAt\": null\n  },\n// 面积图配置格式\n  \"AREA\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"AREA\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"面积图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 条形图配置格式\n  \"BAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"BAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"条形图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": true,\n    \"labelHideOverlap\": true,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"categoryAxisMaxRatio\": \"0.3\",\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"filter\": []\n  }\n// 按钮配置格式,必须按照此格式配置,否则会导致解析错误\n  \"BUTTON\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"BUTTON\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"按钮\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,按钮样式类型,合法值有:type1 到 type10,默认为 type1\n    \"buttonStyleType\": \"type1\",\n    // 可选,边框宽度,默认为 1\n    \"borderWidth\": 1,\n    // 可选,按钮大小,合法值有:default(默认)/ small(小)/ large(大),默认为 default\n    \"buttonSize\": \"default\",\n    // 可选,按钮图标,合法值有:link / download / refresh 等\n    \"buttonIcon\": \"link\",\n    // 可选,按钮图标背景类型,合法值有:light(浅色)/ dark(深色),默认为 light\n    \"buttonIconBackgroundType\": \"light\",\n    // 可选,按钮字体配置\n    \"buttonFontSetting\": {\n      // 字体大小,默认为 14\n      \"size\": 14,\n      // 字体颜色\n      \"color\": \"\",\n      // 字间距,默认为 0\n      \"letterSpacing\": 0,\n      // 是否加粗,默认为 false\n      \"bold\": false,\n      // 是否斜体,默认为 false\n      \"italic\": false,\n      // 是否下划线,默认为 false\n      \"underline\": false\n    }\n  },\n// 日历视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"CALENDAR_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"CALENDAR_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"日历视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 视图分组配置,用于设置视图的默认分组规则\n    \"viewGroup\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 日历自定义配置,这是日历视图的核心配置\n    \"calendarCustom\": {\n      // 必填,显示字段 ID(必须是该表中真实存在的字段 ID)\n      // 用于在日历中显示事件标题或描述\n      \"displayFieldId\": \"fld_text_01\",\n      // 必填,开始时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置事件的开始时间\n      \"startField\": \"fld_date_01\",\n      // 必填,结束时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置事件的结束时间\n      \"endField\": \"fld_date_02\",\n      // 标记配置,用于在日历中添加特殊标记或样式\n      \"markers\": [],\n      // 时间轴刻度,合法值有:day(日)/ week(周)/ month(月)\n      // 用于设置日历的显示粒度\n      \"timelineScale\": \"month\",\n      // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\n      \"widthMap\": {},\n      // 单元格高度,单位为像素,默认为 32\n      \"cellHeight\": 32\n    },\n    // 工具栏配置,用于控制日历视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 卡片列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"CARD_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"CARD_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"卡片列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置卡片列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置卡片列表的显示方式和布局\n    \"listConfig\": {},\n    // 工具栏配置,用于控制卡片列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// 折叠列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"COLLAPSIBLE_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"COLLAPSIBLE_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"折叠列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置折叠列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置折叠列表的显示方式和布局\n    \"listConfig\": {},\n    // 工具栏配置,用于控制折叠列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// 组合布局配置格式,必须按照此格式配置,否则会导致解析错误\n  \"COMPOSITE_LAYOUT\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"COMPOSITE_LAYOUT\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"组合布局\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\"\n    // 组合布局容器,无特殊配置字段,用于承载其他图表组件\n  },\n// 组合图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"COMPOSITION\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"COMPOSITION\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"组合图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ],\n        \"composition\": {\n          // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"HISTOGRAM\",\n          \"yAxisPosition\": \"left\"\n        }\n      },\n      {\n        \"value\": \"fld_number_02\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"average\"\n          }\n        ],\n        \"composition\": {\n          // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LINE\",\n          \"yAxisPosition\": \"right\"\n        }\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:倒计时(COUNTDOWN)\n  // 别名/同义词:倒计时、正计时、计时器、倒计时卡片\n  // 用途:用于展示距离某个目标日期的剩余时间或已过时间,\n  //       支持倒计时和正计时两种模式。适合展示活动截止时间、\n  //       项目里程碑、重要日期提醒等场景。\n  // ═══════════════════════════════════════════════════════════════════\n  // ⚠️ 重要注意事项:\n  //   1. fixedDatetime 必须是毫秒时间戳(如 1746028800000),不是日期字符串(如 \"2026-05-01\")!\n  //   2. unit 是一个对象(Object),不是字符串!不要写成 \"天\" 或 \"day\"!\n  //   3. 背景色字段名是 \"background\",不是 \"backgroundColor\"\n  //   4. 时间数字颜色字段名是 \"timeColor\",不是 \"numberColor\"\n  //   5. 描述文字颜色字段名是 \"descColor\",不是 \"titleColor\"\n  //   6. 不存在 \"countdownType\"、\"endDate\"、\"numberColor\"、\"titleColor\" 等字段,使用这些字段会导致图表渲染异常\n  \"COUNTDOWN\": {\n    // 必填,图表类型,固定值 \"COUNTDOWN\"\n    \"chartType\": \"COUNTDOWN\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"倒计时\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": null,\n    // 可选,描述文本,显示在倒计时数字上方\n    \"desc\": \"倒计时\",\n    // 必填,数据来源类型,合法值:fixed(固定日期时间)/ field(从数据表日期字段取值)\n    \"sourceType\": \"fixed\",\n    // sourceType = \"fixed\" 时必填,目标日期的毫秒时间戳\n    // ⚠️ 必须是毫秒时间戳数字(如 1746028800000),不是日期字符串(如 \"2026-05-01\")!\n    // 示例:2026-05-01 00:00:00 → 1746028800000\n    \"fixedDatetime\": 1746028800000,\n    // sourceType = \"field\" 时填写,日期字段的 fieldId\n    \"fieldId\": null,\n    // sourceType = \"field\" 时填写,日期选择方式,合法值:earliest(最早日期)/ latest(最晚日期)\n    \"fieldChoice\": \"earliest\",\n    // 必填,计时模式,合法值:countdown(倒计时,目标时间在未来)/ countup(正计时,目标时间在过去)\n    \"timingMode\": \"countdown\",\n    // 必填,显示的时间单位开关(是一个对象,不是字符串!)\n    // ⚠️ 不要写成 \"天\" 或 \"day\",必须是如下对象格式:\n    \"unit\": {\n      // 是否显示月,默认 false\n      \"month\": false,\n      // 是否显示周,默认 false\n      \"week\": false,\n      // 是否显示天,默认 true\n      \"day\": true,\n      // 是否显示小时,默认 true\n      \"hour\": true,\n      // 是否显示分钟,默认 true\n      \"minute\": true,\n      // 是否显示秒,默认 true\n      \"second\": true\n    },\n\n    // ── 样式配置 ──────────────────────────────────────────────────────────────\n    // 可选,卡片背景颜色(⚠️ 字段名是 \"background\",不是 \"backgroundColor\")\n    \"background\": \"\",\n    // 可选,描述文字颜色(⚠️ 字段名是 \"descColor\",不是 \"titleColor\")\n    \"descColor\": \"\",\n    // 可选,时间块背景颜色\n    \"timeBlockColor\": \"\",\n    // 可选,倒计时数字颜色(⚠️ 字段名是 \"timeColor\",不是 \"numberColor\")\n    \"timeColor\": \"\",\n    // 可选,计时完成是否显示动效,默认 false\n    \"showFinishEffect\": false\n  },\n// 详情列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"DETAIL_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"DETAIL_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"详情列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置详情列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置详情列表的显示方式和布局\n    \"listConfig\": {},\n    // 工具栏配置,用于控制详情列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// 嵌入网页配置格式,必须按照此格式配置,否则会导致解析错误\n  \"EMBED_WEBPAGE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"EMBED_WEBPAGE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"嵌入网页\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 必填,嵌入的网页 URL(必须是有效的 URL 地址)\n    \"url\": \"https://example.com\"\n  },\n// 漏斗图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"FUNNEL\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"FUNNEL\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"漏斗图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"sumValue\": true,\n    \"funnelType\": \"normal\",\n    \"filter\": []\n  },\n// 画册视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"GALLERY_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"GALLERY_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"画册视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 画册自定义配置,用于设置画册的显示样式\n    \"galleryCustom\": {\n      // 卡片设置,用于配置画册中卡片的显示方式\n      \"cardSetting\": {\n        // 封面模式,合法值有:none(无封面)/ cover(封面)等\n        \"coverMode\": \"none\"\n      }\n    },\n    // 工具栏配置,用于控制画册视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 甘特视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"GANTT_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"GANTT_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"甘特视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 视图分组配置,用于设置视图的默认分组规则\n    \"viewGroup\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 是否折叠网格,默认为 false\n    \"collapsedGrid\": false,\n    // 甘特自定义配置,这是甘特视图的核心配置\n    \"ganttCustom\": {\n      // 必填,显示字段 ID(必须是该表中真实存在的字段 ID)\n      // 用于在甘特图中显示任务名称或描述\n      \"displayFieldId\": \"fld_text_01\",\n      // 必填,开始时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置任务的开始时间\n      \"startField\": \"fld_date_01\",\n      // 必填,结束时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置任务的结束时间\n      \"endField\": \"fld_date_02\",\n      // 标记配置,用于在甘特图中添加里程碑或重要时间点\n      \"markers\": [],\n      // 时间轴刻度,合法值有:day(日)/ week(周)/ month(月)/ quarter(季)/ year(年)\n      // 用于设置甘特图的时间轴显示粒度\n      \"timelineScale\": \"month\",\n      // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\n      \"widthMap\": {},\n      // 单元格高度,单位为像素,默认为 32\n      \"cellHeight\": 32\n    },\n    // 工具栏配置,用于控制甘特视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 表格视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"GRID_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"GRID_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"表格视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 视图分组配置,用于设置视图的默认分组规则\n    \"viewGroup\": [],\n    // 单元格高度,单位为像素,默认为 32\n    \"cellHeight\": 32,\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\n    \"widthMap\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 冻结列数,默认为 1,表示冻结左侧的列数\n    \"frozenColCount\": 1,\n    // 工具栏配置,用于控制表格视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 创建行按钮的文本内容\n      \"createRowText\": \"\",\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:柱状图(HISTOGRAM)\n  // 别名/同义词:竖向柱图、垂直柱图、分组柱图\n  // 用途:以垂直柱子展示多个分类维度的数值对比,适合比较不同类别之间的\n  //       数量差异,如各月销售额对比、各部门人数对比、各产品销量排名等。\n  //       核心特征:有 X 轴(分类维度)和 Y 轴(数值),每个类别对应一根柱子。\n  // 与\"统计数字(SUMMARY)\"的关键区别:\n  //   - 柱状图(HISTOGRAM):展示多个分类的对比,有坐标轴,是\"多值图表\"\n  //   - 统计数字(SUMMARY):只展示一个聚合数字,无坐标轴,是\"单值卡片\"\n  // 重要:当用户说\"柱状图\"、\"柱图\"、\"分组柱图\"时,\n  //       必须使用 chartType = \"HISTOGRAM\",不要使用 SUMMARY 或其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  \"HISTOGRAM\": {\n    // 必填,图表类型,固定值 \"HISTOGRAM\"(柱状图)\n    \"chartType\": \"HISTOGRAM\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"柱状图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"xSortCustom\": [],\n    \"ySortCustom\": [],\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"axisLabelShow\": false,\n    \"label\": true,\n    \"labelHideOverlap\": true,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"xAxisLabelRotate\": \"auto\",\n    \"categoryAxisMaxRatio\": \"0.2\",\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"yAxisRange\": {\n      \"type\": \"absolute\",\n      \"range\": []\n    },\n    \"xAbbr\": {\n      \"numberType\": \"default\",\n      \"abbr\": \"\",\n      \"decimal\": \"\"\n    },\n    \"yAbbr\": {\n      \"numberType\": \"default\",\n      \"abbr\": \"\",\n      \"decimal\": \"\"\n    },\n    \"yRightAbbr\": {\n      \"numberType\": \"default\",\n      \"abbr\": \"\",\n      \"decimal\": \"\"\n    },\n    \"filter\": []\n  }\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:图片(IMAGE)\n  // 别名/同义词:图片卡、图片展示、图片轮播、Banner 图\n  // 用途:在仪表盘中展示一张或多张静态图片,支持多图轮播(滑动/淡入淡出),\n  //       可为每张图片配置标题、描述和点击跳转链接。适合展示品牌 Banner、\n  //       产品图、说明图、装饰图等不依赖数据表的纯图片内容。\n  // 重要:当用户说\"图片\"、\"图片卡\"、\"图片展示\"、\"Banner\"、\"轮播图\"时,\n  //       必须使用 chartType = \"IMAGE\",不要使用其他图表类型。\n  //       此图表类型不依赖数据表,无需配置 sheet / view / filter。\n  // ═══════════════════════════════════════════════════════════════════\n  \"IMAGE\": {\n    // 必填,图表类型,固定值 \"IMAGE\"(图片)\n    \"chartType\": \"IMAGE\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"图片\",\n    // 必填,图片列表,至少包含一张图片;多张图片时支持轮播\n    \"img_images\": [\n      {\n        // 必填,图片的访问 URL\n        \"url\": \"https://example.com/image.png\",\n        // 可选,图片标题,显示在图片上方(需 img_showImgText=true)\n        \"title\": \"\",\n        // 可选,图片描述,显示在标题下方(需 img_showImgText=true)\n        \"desc\": \"\",\n        // 可选,点击图片后跳转的链接 URL\n        \"toUrl\": \"\",\n        // 必填,图片填充模式,合法值:cover(裁剪铺满)/ contain(等比缩放留白)/ fill(拉伸铺满)\n        \"fillMode\": \"cover\"\n      }\n    ],\n    // 可选,是否在图片上显示标题和描述文字,默认 true;\n    // 设为 false 时 img_titleStyle / img_descStyle / img_textAlign 均不生效\n    \"img_showImgText\": true,\n    // 可选,图片文字的对齐位置,合法值:\n    //   leftTop / top / rightTop / left / center / right / leftBottom / bottom / rightBottom\n    \"img_textAlign\": \"center\",\n    // 可选,图片标题的字体样式(仅 img_showImgText=true 时生效)\n    \"img_titleStyle\": {\n      // 字体大小(px),合法范围 16~56,步长 4\n      \"size\": 16,\n      // 字体颜色,十六进制色值,如 \"#FFFFFF\"\n      \"color\": \"\",\n      // 是否加粗\n      \"bold\": true,\n      // 是否斜体\n      \"italic\": false,\n      // 是否下划线\n      \"underline\": false\n    },\n    // 可选,图片描述的字体样式(仅 img_showImgText=true 时生效)\n    \"img_descStyle\": {\n      // 字体大小(px),合法范围 16~56,步长 4\n      \"size\": 14,\n      // 字体颜色,十六进制色值\n      \"color\": \"\",\n      // 是否加粗\n      \"bold\": false,\n      // 是否斜体\n      \"italic\": false,\n      // 是否下划线\n      \"underline\": false\n    },\n    // 可选,多图时是否自动轮播,默认 false;单张图片时此配置无效\n    \"img_autoPlay\": false,\n    // 可选,多图轮播切换动效,合法值:slide(滑动)/ fade(淡入淡出),默认 slide;单张图片时无效\n    \"img_imgTransitionType\": \"slide\",\n    // 可选,多图自动轮播间隔(秒),合法范围 0.1~1000,默认 1;仅 img_autoPlay=true 时生效\n    \"img_imgTransitionInterval\": 1\n  },\n// 看板视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"KANBAN_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"KANBAN_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"看板视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 看板自定义配置,这是看板视图的核心配置\n    \"kanbanCustom\": {\n      // 分组基础配置,用于设置看板的分组字段\n      \"groupBase\": {\n        // 必填,分组字段 ID(必须是该表中真实存在的字段 ID)\n        // 看板将按照此字段的值进行分组显示\n        \"baseFieldId\": \"fld_select_01\"\n      }\n    },\n    // 工具栏配置,用于控制看板视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 排行榜配置格式,必须按照此格式配置,否则会导致解析错误\n  \"LEADERBOARD\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LEADERBOARD\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"排行榜\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_text_01\"\n      }\n    ],\n    \"showType\": \"podium\", // 展示类型:podium 领奖台/list 列表\n    \"showCount\": 10, // 显示条数\n    \"hideValue\": false,\n    \"sortRule\": \"asc\",\n    \"filter\": []\n  },\n// 折线图配置格式\n  \"LINE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LINE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"折线图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": true,\n    \"labelHideOverlap\": true,\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"filter\": []\n  }\n// 抽奖配置格式,必须按照此格式配置,否则会导致解析错误\n  \"LOTTERY\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LOTTERY\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"抽奖\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 必填,指标类型,合法值有:record-count(记录数)/ field(字段)\n    \"measureType\": \"record-count\",\n    // 必填,指标配置数组\n    \"measure\": [],\n    // 可选,输出表 ID,用于记录中奖结果\n    \"outputSheet\": \"\",\n    // 可选,滚动速度,默认为 5\n    \"speed\": 5,\n    // 可选,字体大小,默认为 20\n    \"fontSize\": 20,\n    // 可选,颜色主题,合法值有:COLOR_PALETTE_1 到 COLOR_PALETTE_10\n    \"colors\": \"COLOR_PALETTE_1\",\n    // 必填,奖项配置数组,每个元素格式为 [数量, 名称]\n    \"gameRounds\": [\n      [\n        1,\n        \"一等奖\"\n      ]\n    ],\n    // 可选,是否包含已中奖者,默认为 false\n    \"includeWinners\": false\n  },\n// 跑马灯配置格式,必须按照此格式配置,否则会导致解析错误\n  \"MARQUEE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"MARQUEE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"跑马灯\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,滚动文本内容\n    \"content\": \"这是一条滚动消息\",\n    // 可选,字体大小,默认为 14\n    \"fontSize\": 14,\n    // 可选,滚动速度,数值越小速度越快,默认为 50\n    \"speed\": 50,\n    // 可选,字体配置\n    \"fontSetting\": {\n      // 字体颜色\n      \"color\": \"\",\n      // 是否加粗,默认为 false\n      \"bold\": false,\n      // 是否斜体,默认为 false\n      \"italic\": false,\n      // 是否下划线,默认为 false\n      \"underline\": false\n    }\n  },\n// NPS评分配置格式,必须按照此格式配置,否则会导致解析错误\n  \"NPS\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"NPS\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"NPS评分\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\"\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"showLabel\": true,\n    \"showList\": false,\n    \"threshold\": [ // 评分阈值数组\n      2,\n      3\n    ],\n    \"colorSetName\": \"COLOR_PALETTE_1\",\n    \"filter\": []\n  },\n// 百分比堆叠面积图配置格式\n  \"PERCENT_STACKED_AREA\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PERCENT_STACKED_AREA\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"百分比堆叠面积图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 百分比堆叠条形图配置格式\n  \"PERCENT_STACKED_BAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PERCENT_STACKED_BAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"百分比堆叠条形图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"categoryAxisMaxRatio\": \"0.3\",\n    \"filter\": []\n  }\n// 百分比堆叠柱状图配置格式\n  \"PERCENT_STACKED_HISTOGRAM\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PERCENT_STACKED_HISTOGRAM\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"百分比堆叠柱状图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 饼图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"PIE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PIE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"饼图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"colors\": \"COLOR_PALETTE_1\", // 必须使用示例中的默认值如 COLOR_PALETTE_1,禁止自行编造\n    \"legend\": \"none\",\n    \"label\": true, // 必须设为 true 以默认显示数据标签\n    \"innerRadius\": 0, // 内半径,0 为实心饼图\n    \"outerRadius\": 60, // 外半径百分比\n    \"filter\": []\n  },\n// 透视表配置格式,必须按照此格式配置,否则会导致解析错误\n  \"PIVOT\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PIVOT\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"透视表\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 透视表配置,这是透视表的核心配置\n    \"pivotConfig\": {\n      // 必填,rows 是行字段列表,至少要有一个行字段,\n      // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\n      // 如果多层分组,行字段列表中多个字段的 fieldId 依次对应每层分组的字段 ID\n      \"rows\": [\n        {\n          // 必填,行字段 ID(必须是该表中真实存在的字段 ID)\n          \"fld\": \"fld_01\",\n          // 可选,排序方式,合法值有:1(升序)和 -1(降序),默认为 1 升序\n          \"srt\": 1\n        }\n      ],\n      // 可选,columns 是列字段列表,默认为空数组,\n      // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\n      // 如果多层分组,列字段列表中多个字段的 fieldId 依次对应每层分组的字段 ID\n      \"columns\": [\n        {\n          // 必填,列字段 ID(必须是该表中真实存在的字段 ID)\n          \"fld\": \"fld_02\",\n          // 可选,排序方式,合法值有:1(升序)和 -1(降序),默认为 1 升序\n          \"srt\": 1\n        }\n      ],\n      // 必填,values 是值字段列表,至少要有一个值字段,\n      // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\n      // 值字段,是用于计算值的字段,通常是数值字段,但也可以是其他类型的字段(如文本字段),具体取决于聚合函数的要求\n      \"values\": [\n        {\n          // 必填,值字段 ID(必须是该表中真实存在的字段 ID)\n          \"fld\": \"fld_03\",\n          //必填,聚合函数,合法值有:sum /count / count_distinct / avg / min / max / median / population_variance / sample_variance / population_stddev / sample_stddev / first / last / range / mode / sum_of_squares / harmonic_mean / geometric_mean / interquartile_range / centered_mean / weighted_mean / kurtosis / skewness / quartile_median / range_median / coefficient_of_variation / adjusted_stddev / weighted_sum / weighted_count / weighted_count_distinct / weighted_avg / centered_weighted_mean / weighted_harmonic_mean / weighted_geometric_mean / weighted_range / weighted_range_median / weighted_interquartile_range / weighted_quartile_median / weighted_centered_mean / weighted_adjusted_stddev\n          \"agg\": \"sum\",\n          //可选,后聚合函数,合法值有:default / percent_of_row_total / percent_of_col_total / percent_of_grand_total\n          \"pgg\": \"string\",\n          //可选,格式化函数,合法值有:default / percentage / thousands / currency / scientific / fixed / integer\n          \"fmt\": \"\",\n          //可选,字段别名\n          \"als\": \"string\"\n        }\n      ],\n      // 是否显示行总计,默认为 true\n      \"rowTotals\": true,\n      // 是否显示列总计,默认为 true\n      \"columnTotals\": true\n    },\n    \"pivotStyle\": {\n      // 可选,透视表在卡片中的对齐方式,合法值有: start(上部)/ center(中间)\n      \"verticalAlign\": \"start\"\n    }\n  },\n// 地图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"PLOT_MAP\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PLOT_MAP\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"地图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 地图配置,这是地图的核心配置\n    \"plotMapConfig\": {\n      // 数据源配置\n      \"source\": {\n        // 数据源类型,合法值有:view(视图)\n        \"type\": \"view\",\n        // 数据源名称,格式为 \"sheetId:viewId\"\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"sheet_001:view_001\"\n      },\n      // 渲染器类型,合法值有:marker(标记点)/ heatmap(热力图)等\n      // 用于设置地图的渲染方式\n      \"renderer\": \"marker\",\n      // 处理器,用于对数据进行预处理\n      \"processor\": \"\",\n      // 维度配置,用于设置地图的维度字段(如地理位置字段)\n      \"dimensions\": [\n        {\n          // 必填,维度字段 ID(必须是该表中真实存在的字段 ID)\n          // 通常为位置字段,用于在地图上定位\n          \"fieldId\": \"fld_location_01\"\n        }\n      ],\n      // 度量配置,用于设置地图的度量字段(如数值字段)\n      \"measures\": [\n        {\n          // 必填,度量字段 ID(必须是该表中真实存在的字段 ID)\n          // 用于在地图上显示数值大小或颜色\n          \"fieldId\": \"fld_number_01\"\n        }\n      ],\n      // 样式配置,用于设置地图的显示样式\n      \"styles\": []\n    },\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:进度图(PROGRESS)\n  // 别名/同义词:进度条、进度环、进度卡、进度图表\n  // 用途:用于展示某项目标的完成进度,支持当前值 vs 目标值的对比,\n  //       可视化呈现完成百分比。适合展示销售目标达成率、任务完成度、\n  //       日期时间进度等场景。\n  // 重要:当用户说\"进度图\"、\"进度条\"、\"进度环\"、\"目标完成度\"时,\n  //       必须使用 chartType = \"PROGRESS\",不要使用其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  //\n  // ⚠️⚠️⚠️ 极其重要的注意事项(不遵守会导致图表渲染报错):\n  //\n  //   1. currentMeasure / targetMeasure 的格式是一个对象(Object),绝对不是数组(Array)!\n  //      不要使用普通图表的 measure 数组格式 [{ externalValue, value }],否则图表会报错!\n  //\n  //   2. 必须包含以下顶层通用字段,否则图表校验会失败并报「表格字段发生变化」错误:\n  //      - \"measureType\": \"record-count\"  ← 必填!缺少此字段图表必定报错\n  //      - \"sheet\": \"\u003csheetId\u003e\"           ← 必填!设置为 currentSheet 相同的值\n  //      - \"view\": \"\"                     ← 必填!设置为空字符串\n  //      - \"filter\": []                   ← 必填!设置为空数组\n  //\n  //   3. currentFilter / targetFilter 中的每个筛选条件必须包含 \"link\" 字段:\n  //      正确:{ \"fieldId\": \"xxx\", \"symbol\": \"EQ\", \"value\": \"yyy\", \"link\": \"AND\" }\n  //      错误:{ \"fieldId\": \"xxx\", \"symbol\": \"EQ\", \"value\": \"yyy\" }  ← 缺少 link!\n  //      注意:单选字段的 value 必须是选项的 ID(如 \"PO5PWsAqQi\"),不能是选项的显示文本(如 \"影院观影\")!\n  //      使用文本作为 value 会导致筛选条件无法匹配任何记录,当前值始终为 0。\n  //\n  //   4. 即使 targetSource 为 \"custom\",也必须提供 targetSheet、targetView、targetFilter、targetMeasure 的默认值\n  //      即使 currentSource 为 \"custom\",也必须提供 currentSheet、currentView、currentFilter、currentMeasure 的默认值\n  //\n  //   5. 必须包含 numberFormat 和 fixedCount 字段,否则当前值/目标值会显示异常(数值被乘以100、出现双%%、文案被挤掉不显示):\n  //      - \"numberFormat\": { \"digits\": 0, \"format\": \"number\", \"thousandSeparator\": true }  ← format 必须是 \"number\"!\n  //      - \"fixedCount\": 0\n  //\n  \"PROGRESS\": {\n    // ── 顶层通用字段(必填,缺少会导致图表校验失败)─────────────────────────\n    // 必填,图表类型,固定值 \"PROGRESS\"(进度图)\n    \"chartType\": \"PROGRESS\",\n    // ⚠️ 必填,统计类型,进度图固定为 \"record-count\"\n    \"measureType\": \"record-count\",\n    // ⚠️ 必填,顶层数据表 ID,设置为 currentSheet 相同的值\n    \"sheet\": \"\u003csheetId\u003e\",\n    // ⚠️ 必填,顶层视图 ID,设置为空字符串\n    \"view\": \"\",\n    // ⚠️ 必填,顶层筛选条件,设置为空数组\n    \"filter\": [],\n\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"进度图\",\n    // 必填,数据类型,合法值:number(数值进度)/ date(日期进度)\n    \"dataType\": \"number\",\n    // 必填,图表形状,合法值:bar(条形)/ semi-circle(半环)/ circle(圆环)\n    \"progressType\": \"bar\",\n\n    // ── 目标值配置 ──────────────────────────────────────────────────────────\n    // 目标值来源,合法值:custom(自定义数值)/ sheet(从数据表字段聚合)\n    \"targetSource\": \"custom\",\n    // targetSource = \"custom\" 时填写,目标值的固定数值字符串\n    \"target\": \"500\",\n    // 必填,目标值来源的数据表 ID,即使 targetSource = \"custom\" 也要填写,设置为 currentSheet 相同的值\n    \"targetSheet\": \"\u003csheetId\u003e\",\n    // 必填,目标值来源的视图 ID,设置为空字符串\n    \"targetView\": \"\",\n    // 必填,目标值来源的过滤条件,targetSource = \"custom\" 时设置为空数组\n    \"targetFilter\": [],\n    // 必填,目标值聚合字段配置,targetSource = \"custom\" 时设置为默认值\n    // ⚠️ 格式必须是对象,不是数组!详见下方 currentMeasure 的格式说明,targetMeasure 格式完全相同\n    \"targetMeasure\": { \"type\": \"record-count\", \"fields\": [] },\n    // 可选,目标值的标签文字,显示在目标值旁边\n    \"targetText\": \"\",\n\n    // ── 当前值配置 ──────────────────────────────────────────────────────────\n    // 当前值来源,合法值:custom(自定义数值)/ sheet(从数据表字段聚合)\n    \"currentSource\": \"custom\",\n    // currentSource = \"custom\" 时填写,当前值的固定数值字符串\n    \"current\": \"100\",\n    // 必填,当前值来源的数据表 ID\n    \"currentSheet\": \"\u003csheetId\u003e\",\n    // 必填,当前值来源的视图 ID,设置为空字符串表示全部数据\n    \"currentView\": \"\",\n    // 必填,当前值来源的过滤条件,currentSource = \"custom\" 时设置为空数组\n    // ⚠️ 每个筛选条件必须包含 \"link\" 字段!格式:\n    //   { \"fieldId\": \"\u003cfieldId\u003e\", \"symbol\": \"EQ\", \"value\": \"\u003c选项ID\u003e\", \"link\": \"AND\" }\n    //   多个条件时,第一个条件的 link 也必须填 \"AND\"\n    \"currentFilter\": [],\n    // 必填,当前值聚合字段配置\n    // ═══════════════════════════════════════════════════════════════════\n    // ⚠️⚠️⚠️ 极其重要:currentMeasure 是一个 Object,不是 Array!\n    // 绝对不要写成 [{ externalValue: [...], value: \"xxx\" }] 这种数组格式!\n    // 正确格式如下:\n    //\n    // 方式一:记录计数(统计满足条件的记录总数)\n    //   { \"type\": \"record-count\", \"fields\": [] }\n    //\n    // 方式二:字段聚合(对某个字段做聚合计算)\n    //   { \"type\": \"field\", \"fields\": [{ \"value\": \"\u003cfieldId\u003e\", \"formula\": \"\u003c聚合函数\u003e\" }] }\n    //   formula 合法值:count / unique-count / sum / average / max / min\n    //\n    // 错误示例(绝对禁止):\n    //   [{ \"externalValue\": [{ \"type\": \"formula\", \"value\": \"count\" }], \"value\": \"fieldId\" }]\n    // ═══════════════════════════════════════════════════════════════════\n    \"currentMeasure\": { \"type\": \"record-count\", \"fields\": [] },\n    // 可选,当前值的标签文字,显示在当前值旁边\n    \"currentText\": \"\",\n\n    // ── 数值格式配置 ──────────────────────────────────────────────────────────\n    // ⚠️ 必填,百分比小数位数,缺少会导致小数位数异常,默认填 0\n    \"fixedCount\": 0,\n    // ⚠️ 必填,数字格式化配置(作用于当前值和目标值的显示)\n    // 缺少此字段会导致数值显示异常(数值被乘以100、出现双%%符号、文案被挤掉不显示)\n    \"numberFormat\": {\n      // 小数位数,默认 0\n      \"digits\": 0,\n      // ⚠️ 格式类型,进度图必须填 \"number\"!绝对不要填 \"percent\",否则数值会被乘以100并多出%符号\n      // 合法值:number / percent / thousand,进度图固定填 \"number\"\n      \"format\": \"number\",\n      // 是否显示千位分隔符\n      \"thousandSeparator\": true\n    },\n    // 可选,数值单位,如 \"¥\" / \"$\" / \"人\" / \"%\" 等自定义字符串,不填则无单位\n    \"modifier\": \"\",\n    // 可选,单位位置,合法值:left(左)/ right(右),默认 left\n    \"modifierPosition\": \"left\",\n    // 可选,目标方向,合法值:increase(越大越好)/ decrease(越小越好),影响进度条配色逻辑\n    \"targetDirection\": \"increase\",\n\n    // ── 日期进度(dataType = \"date\")专属配置 ────────────────────────────────\n    // 目标日期范围,合法值:month(本月)/ quarter(本季度)/ year(今年)/ custom(自定义)\n    \"targetDateSelected\": \"month\",\n    // targetDateSelected = \"custom\" 时填写,自定义目标日期范围 [开始时间戳, 结束时间戳]\n    \"targetDateRange\": null,\n    // 当前日期,合法值:today(今日)/ yesterday(昨日)/ custom(自定义)\n    \"currentDateSelected\": \"today\",\n    // currentDateSelected = \"custom\" 时填写,自定义当前日期时间戳\n    \"currentDate\": null,\n    // 日期格式,如 \"YYYY-MM-DD\",仅在 targetDateSelected 或 currentDateSelected 为 custom 时生效\n    \"dateFormat\": \"YYYY-MM-DD\",\n\n    // ── 样式配置 ──────────────────────────────────────────────────────────────\n    // 可选,卡片背景颜色,十六进制色值,如 \"#F0F5FF\"\n    \"background\": \"\",\n    // 可选,百分比数字颜色,十六进制色值,如 \"#1677FF\"\n    \"percentageColor\": \"\",\n    // 可选,进度条填充颜色,支持两种形式:\n    //   1. 固定色值字符串,如 \"#1677FF\",或渐变色 \"linear-gradient(...)\"(圆环图支持渐变)\n    //   2. 条件配色对象(根据百分比区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\n    \"strokeColor\": \"\",\n    // 可选,进度条背景颜色\n    \"strokeBgColor\": \"\",\n    // 可选,当前值文字颜色(仅 dataType=number 时生效)\n    \"currentColor\": \"\",\n    // 可选,目标值文字颜色\n    \"targetColor\": \"\"\n  },\n// 查询页配置格式,必须按照此格式配置,否则会导致解析错误\n  \"QUERY_PAGE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"QUERY_PAGE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"查询页\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"\",\n    // 查询页无其他特殊配置字段,\n    // 主要用于提供数据查询和展示的基础功能\n  },\n// 雷达图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"RADAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"RADAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"雷达图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"y\",\n    \"sortRule\": \"asc\",\n    \"radarShape\": \"circle\",\n    \"isFilled\": true,\n    \"gridShow\": true,\n    \"yAxisLine\": true,\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"filter\": []\n  },\n// 富文本配置格式,必须按照此格式配置,否则会导致解析错误\n  \"RICH_TEXT\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"RICH_TEXT\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"富文本\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,富文本内容,支持 HTML 格式\n    \"content\": \"\"\n  },\n// 玫瑰图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"ROSE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"ROSE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"玫瑰图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"none\",\n    \"label\": true,\n    \"innerRadius\": 0,\n    \"outerRadius\": 60,\n    \"filter\": []\n  },\n// 散点图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"SCATTER\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"SCATTER\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"散点图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_number_02\",\n        \"externalValue\": []\n      }\n    ],\n    \"smooth\": false,\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"filter\": []\n  },\n// 筛选器配置格式,必须按照此格式配置,否则会导致解析错误\n  \"SLICER\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"SLICER\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"筛选器\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,筛选器样式,合法值有:button(按钮)/ dropdown(下拉),默认为 button\n    \"sli_filterStyle\": \"button\",\n    // 可选,排序方式,合法值有:asc(升序)/ desc(降序),默认为 asc\n    \"sli_orderBy\": \"asc\",\n    // 可选,是否多选,默认为 true\n    \"sli_multiple\": true,\n    // 可选,是否显示数量,默认为 true\n    \"sli_showCount\": true,\n    // 可选,日期操作符,合法值有:BEFORE / AFTER / BETWEEN 等\n    \"sli_dateOperator\": \"BEFORE\",\n    // 可选,数字操作符,合法值有:EQ(等于)/ NE(不等于)/ GT(大于)等\n    \"sli_numberOperator\": \"EQ\",\n    // 可选,关联到图表的方式,合法值有:auto(自动)/ manual(手动),默认为 auto\n    \"sli_2Chart\": \"auto\",\n    // 可选,关联到其他筛选器的方式,合法值有:auto(自动)/ manual(手动),默认为 auto\n    \"sli_2Slicer\": \"auto\",\n    // 可选,对齐方式,合法值有:top(顶部)/ bottom(底部),默认为 top\n    \"sli_align\": \"top\",\n    // 可选,选择样式,合法值有:1 / 2 / 3 等,默认为 1\n    \"sli_selectStyle\": \"1\",\n    // 必填,绑定的字段 ID(必须是该表中真实存在的字段 ID)\n    \"sli_fieldId\": \"fld_select_01\"\n  },\n// 堆叠面积图配置格式\n  \"STACKED_AREA\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_AREA\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠面积图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 堆叠条形图配置格式\n  \"STACKED_BAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_BAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠条形图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"bottom\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"categoryAxisMaxRatio\": \"0.3\",\n    \"filter\": []\n  }\n// 堆叠柱状图配置格式\n  \"STACKED_HISTOGRAM\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_HISTOGRAM\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠柱状图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"bottom\",\n    \"gridShow\": false,\n    \"label\": false,\n    \"labelHideOverlap\": true,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"filter\": []\n  }\n// 堆叠折线图配置格式\n  \"STACKED_LINE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_LINE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠折线图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 标准列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"STANDARD_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STANDARD_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标准列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置列表的显示方式和布局\n    \"listConfig\": {},\n    // 字段聚合映射,用于设置字段的聚合方式\n    \"fieldAggregateMap\": {},\n    // 工具栏配置,用于控制标准列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:指标卡(STATISTICS)\n  // 别名/同义词:KPI 卡、指标图、数据指标卡、关键指标卡\n  // 用途:以大号数字展示核心指标,并可附加环同比(与上期对比的涨跌幅)\n  //       和趋势折线图,适合展示销售额、DAU、转化率等需要对比分析的 KPI 指标。\n  //       与\"统计数字(SUMMARY)\"的区别:指标卡支持环同比对比和趋势图,\n  //       功能更丰富;统计数字仅展示单一数字,更简洁。\n  // 重要:当用户说\"指标卡\"、\"KPI 卡\"、\"指标图\"、\"关键指标\"、\"带趋势的数字\"时,\n  //       必须使用 chartType = \"STATISTICS\",不要使用其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  \"STATISTICS\": {\n    // 必填,图表类型,固定值 \"STATISTICS\"(指标卡)\n    \"chartType\": \"STATISTICS\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"指标卡\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时为 null,传入时必须是该表中真实存在的 viewId\n    \"view\": null,\n    // 无 filter 时请传空数组;\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置)中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 统计指标配置,measureType 与 measure 二选一:\n    //   measureType = \"record-count\" 时统计记录总数,无需填 measure;\n    //   measureType = \"field\" 时需填 measure,指定聚合字段和聚合函数\n    \"measureType\": \"field\",\n    // 可选,当 measureType = \"field\" 时必填,指定聚合字段\n    \"measure\": [\n      {\n        // 必填,字段 ID(必须是该表中真实存在的数值类字段 ID)\n        \"value\": \"fld_number_01\",\n        // 必填,聚合函数配置\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            // formula 合法值:count (计数), unique-count (去重计数), sum (求和), average (平均值), max (最大值), min (最小值)'\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    // 可选,指标说明文字,显示在数值下方\n    \"desc\": \"\",\n    // 可选,数字格式化配置\n    \"numberFormat\": {\n      // 小数位数,默认 0\n      \"digits\": 0,\n      // 格式类型,合法值:number / percent / thousand\n      \"format\": \"number\",\n      // 是否显示千位分隔符\n      \"thousandSeparator\": true\n    },\n    // 可选,是否显示环同比,默认 false\n    \"showComparison\": false,\n    // 可选,showComparison=true 时填写,环同比对比的日期字段 ID\n    \"comparisonField\": null,\n    // 可选,环同比时间范围,如 \"this_week\" / \"this_month\" / \"this_year\"\n    \"comparisonDateRange\": null,\n    // 可选,环同比类型,合法值:sequential(环比)/ year-on-year(同比)/ month-on-month(月比)/ week-on-week(周比)/ custom(自定义)\n    \"comparisonType\": \"sequential\",\n    // 可选,时间统计方式,合法值:same_period(同期)/ cumulative(累计)\n    \"comparisonPeriodType\": \"same_period\",\n    // 可选,环同比计算类型,合法值:difference-rate(差值率)/ difference-value(差值)/ original-value(原值)\n    \"comparisonCalculationType\": \"difference-rate\",\n    // 可选,环同比布局方向(仅 showComparison=true 且 showTrend=false 时生效),合法值:bottom(下方)/ right(右侧)\n    \"comparisonLayout\": \"bottom\",\n    // 可选,是否显示趋势图,默认 false\n    \"showTrend\": false,\n    // 可选,showTrend=true 时填写,趋势图使用的日期字段 ID\n    \"trendField\": null,\n    // 可选,趋势图时间范围,如 \"this_week\" / \"this_month\" / \"this_year\"\n    \"trendDateRange\": null,\n    // 可选,趋势图布局方向(仅 showTrend=true 且 showComparison=false 时生效),合法值:bottom(下方)/ right(右侧)\n    \"trendLayout\": \"bottom\",\n    // 可选,趋势图颜色,十六进制色值,如 \"#1677FF\"\n    \"trendColor\": \"\",\n    // 可选,环同比图标样式,合法值:1 / 2 / 3 / 4 / 5 / 6 / 7 / 8\n    \"iconStyle\": \"5\",\n    // 可选,卡片标题颜色,十六进制色值\n    \"titleColor\": \"\",\n    // 可选,指标说明文字颜色,十六进制色值\n    \"descColor\": \"\",\n    // 可选,环同比描述颜色,十六进制色值\n    \"comparisonDescColor\": \"\",\n    // 可选,数值颜色配置,支持两种形式:\n    //   1. 固定色值字符串,如 \"#1677FF\",或渐变色 \"linear-gradient(...)\"\n    //   2. 条件配色对象(根据数值区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\n    \"colors\": \"\"\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:统计数字(SUMMARY)\n  // 别名/同义词:汇总数字、统计卡、数字卡、计数卡\n  // 用途:以大号数字形式展示单一聚合指标(如记录总数、字段求和/均值等),\n  //       是最简洁的数据汇总展示方式,适合展示总销售额、总订单数、平均分等。\n  // 与其他图表的关键区别:\n  //   - 与\"指标卡(STATISTICS)\"的区别:统计数字更简洁,无环同比、无趋势图;\n  //     指标卡功能更丰富,支持环同比对比和趋势折线图。\n  //   - 与\"柱状图(HISTOGRAM)\"的关键区别:统计数字只展示一个聚合数字(单值卡片),\n  //     无坐标轴、无分类对比;柱状图展示多个分类的数值对比,有 X/Y 轴(多值图表)。\n  // 重要:当用户说\"统计数字\"、\"汇总数字\"、\"数字卡\"、\"计数\"、\"总数\"时,\n  //       必须使用 chartType = \"SUMMARY\",不要使用 HISTOGRAM 或其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  \"SUMMARY\": {\n    // 必填,图表类型,固定值 \"SUMMARY\"(统计数字)\n    \"chartType\": \"SUMMARY\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"统计数字\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时为 null,传入时必须是该表中真实存在的 viewId\n    \"view\": null,\n    // 无 filter 时请传空数组;\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置)中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 统计指标配置,measureType 与 measure 二选一:\n    //   measureType = \"record-count\" 时统计记录总数,无需填 measure;\n    //   measureType = \"field\" 时需填 measure,指定聚合字段和聚合函数\n    \"measureType\": \"field\",\n    // 可选,当 measureType = \"field\" 时必填,指定聚合字段\n    \"measure\": [\n      {\n        // 必填,字段 ID(必须是该表中真实存在的数值类字段 ID)\n        \"value\": \"fld_number_01\",\n        // 必填,聚合函数配置\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            // formula 合法值:count (计数), unique-count (去重计数), sum (求和), average (平均值), max (最大值), min (最小值)'\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    // 可选,数字格式化配置\n    \"numberFormat\": {\n      // 小数位数,默认 0\n      \"digits\": 0,\n      // 格式类型,合法值:number / percent / thousand\n      \"format\": \"number\",\n      // 是否显示千位分隔符\n      \"thousandSeparator\": true\n    },\n    // 可选,指标说明文字,由用户在编辑器中输入,传给服务端后显示在数值下方\n    \"desc\": \"\",\n    // 可选,数值颜色配置,支持两种形式:\n    //   1. 固定色值字符串,如 \"#1677FF\",或渐变色 \"linear-gradient(...)\"\n    //   2. 条件配色对象(根据数值区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\n    \"colors\": \"\"\n  },\n// 标签页配置格式,必须按照此格式配置,否则会导致解析错误\n  \"TABS\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"TABS\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,标签页样式类型,合法值有:type1 到 type10,默认为 type1\n    \"tabsStyleType\": \"type1\",\n    // 可选,标签页对齐方式,合法值有:left(左对齐)/ center(居中)/ right(右对齐),默认为 left\n    \"tabsAlignType\": \"left\",\n    // 可选,默认激活的标签页 ID,必须与 tabs 数组中的某个 id 匹配\n    \"activeTabId\": \"tab_001\",\n    // 必填,标签页数组\n    \"tabs\": [\n      {\n        // 必填,标签页 ID(必须唯一)\n        \"id\": \"tab_001\",\n        // 必填,标签页名称\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页 1\"\n      },\n      {\n        \"id\": \"tab_002\",\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页 2\"\n      },\n      {\n        \"id\": \"tab_003\",\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页 3\"\n      }\n    ]\n  },\n// 词云图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"WORD_CLOUD\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"WORD_CLOUD\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"词云图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_text_01\"\n      }\n    ],\n    \"disableParticiple\": false, // 是否禁用分词\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"filter\": []\n  },\n}\n",
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Dashboard widgets config example returned. Each key is a chart type (e.g. HISTOGRAM, PIE), and the value is the corresponding widget config example. Use the matching chart type's config as a reference when calling create_chart."
-}
真实 stderr / error
(空)
真实返回
{
-  "data": "// 注意\n// 1. 下述 JSON 示例中的注释(// 开头的注释)仅用于说明,实际使用时请删除,否则会导致解析错误\n// 2. 所有图表类型的 widget config 示例,key 是图表类型,value 是对应的完整 config\n// 3. 创建图表时,必需使用示例中的图表类型和配置格式\n\n\n{\n// 聚合列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"AGGREGATE_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"AGGREGATE_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"聚合列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置聚合列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置聚合列表的显示方式和布局\n    \"listConfig\": {},\n    // 字段聚合映射,用于设置字段的聚合方式(如 sum、count、avg 等)\n    \"fieldAggregateMap\": {},\n    // 工具栏配置,用于控制聚合列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// AI 分析卡片配置格式,必须按照此格式配置,否则会导致解析错误\n  // 注意:AI 分析卡片不依赖数据表,无需配置 sheet / view / filter\n  \"AI_ANALYZE\": {\n    // 必填,图表类型,固定值 \"AI_ANALYZE\"\n    \"chartType\": \"AI_ANALYZE\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"AI 分析卡片\",\n    // 可选,AI 提示词,用自然语言描述希望 AI 分析的内容和问题,\n    // 例如:\"分析本月各产品线的销售额趋势,找出增长最快的类别并给出建议\"\n    \"prompt\": \"\",\n    // 可选,需要 AI 参考分析的图表 ID 列表,数组中每个元素必须是当前仪表盘中已存在的图表 ID;\n    // 不填或传空数组时 AI 将基于提示词独立分析,不关联具体图表数据\n    \"analyzeChartIds\": [],\n    // 可选,指定 AI 模型 ID;不填时使用系统默认模型\n    \"modelId\": \"\",\n    // 只读,AI 生成的 Markdown 分析内容,由系统写入,创建时无需填写\n    \"content\": \"\",\n    // 只读,AI 分析结果的最后更新时间戳(毫秒),由系统写入,创建时无需填写\n    \"lastUpdatedAt\": null\n  },\n// 面积图配置格式\n  \"AREA\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"AREA\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"面积图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 条形图配置格式\n  \"BAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"BAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"条形图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": true,\n    \"labelHideOverlap\": true,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"categoryAxisMaxRatio\": \"0.3\",\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"filter\": []\n  }\n// 按钮配置格式,必须按照此格式配置,否则会导致解析错误\n  \"BUTTON\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"BUTTON\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"按钮\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,按钮样式类型,合法值有:type1 到 type10,默认为 type1\n    \"buttonStyleType\": \"type1\",\n    // 可选,边框宽度,默认为 1\n    \"borderWidth\": 1,\n    // 可选,按钮大小,合法值有:default(默认)/ small(小)/ large(大),默认为 default\n    \"buttonSize\": \"default\",\n    // 可选,按钮图标,合法值有:link / download / refresh 等\n    \"buttonIcon\": \"link\",\n    // 可选,按钮图标背景类型,合法值有:light(浅色)/ dark(深色),默认为 light\n    \"buttonIconBackgroundType\": \"light\",\n    // 可选,按钮字体配置\n    \"buttonFontSetting\": {\n      // 字体大小,默认为 14\n      \"size\": 14,\n      // 字体颜色\n      \"color\": \"\",\n      // 字间距,默认为 0\n      \"letterSpacing\": 0,\n      // 是否加粗,默认为 false\n      \"bold\": false,\n      // 是否斜体,默认为 false\n      \"italic\": false,\n      // 是否下划线,默认为 false\n      \"underline\": false\n    }\n  },\n// 日历视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"CALENDAR_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"CALENDAR_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"日历视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 视图分组配置,用于设置视图的默认分组规则\n    \"viewGroup\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 日历自定义配置,这是日历视图的核心配置\n    \"calendarCustom\": {\n      // 必填,显示字段 ID(必须是该表中真实存在的字段 ID)\n      // 用于在日历中显示事件标题或描述\n      \"displayFieldId\": \"fld_text_01\",\n      // 必填,开始时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置事件的开始时间\n      \"startField\": \"fld_date_01\",\n      // 必填,结束时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置事件的结束时间\n      \"endField\": \"fld_date_02\",\n      // 标记配置,用于在日历中添加特殊标记或样式\n      \"markers\": [],\n      // 时间轴刻度,合法值有:day(日)/ week(周)/ month(月)\n      // 用于设置日历的显示粒度\n      \"timelineScale\": \"month\",\n      // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\n      \"widthMap\": {},\n      // 单元格高度,单位为像素,默认为 32\n      \"cellHeight\": 32\n    },\n    // 工具栏配置,用于控制日历视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 卡片列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"CARD_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"CARD_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"卡片列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置卡片列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置卡片列表的显示方式和布局\n    \"listConfig\": {},\n    // 工具栏配置,用于控制卡片列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// 折叠列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"COLLAPSIBLE_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"COLLAPSIBLE_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"折叠列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置折叠列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置折叠列表的显示方式和布局\n    \"listConfig\": {},\n    // 工具栏配置,用于控制折叠列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// 组合布局配置格式,必须按照此格式配置,否则会导致解析错误\n  \"COMPOSITE_LAYOUT\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"COMPOSITE_LAYOUT\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"组合布局\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\"\n    // 组合布局容器,无特殊配置字段,用于承载其他图表组件\n  },\n// 组合图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"COMPOSITION\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"COMPOSITION\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"组合图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ],\n        \"composition\": {\n          // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"HISTOGRAM\",\n          \"yAxisPosition\": \"left\"\n        }\n      },\n      {\n        \"value\": \"fld_number_02\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"average\"\n          }\n        ],\n        \"composition\": {\n          // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LINE\",\n          \"yAxisPosition\": \"right\"\n        }\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:倒计时(COUNTDOWN)\n  // 别名/同义词:倒计时、正计时、计时器、倒计时卡片\n  // 用途:用于展示距离某个目标日期的剩余时间或已过时间,\n  //       支持倒计时和正计时两种模式。适合展示活动截止时间、\n  //       项目里程碑、重要日期提醒等场景。\n  // ═══════════════════════════════════════════════════════════════════\n  // ⚠️ 重要注意事项:\n  //   1. fixedDatetime 必须是毫秒时间戳(如 1746028800000),不是日期字符串(如 \"2026-05-01\")!\n  //   2. unit 是一个对象(Object),不是字符串!不要写成 \"天\" 或 \"day\"!\n  //   3. 背景色字段名是 \"background\",不是 \"backgroundColor\"\n  //   4. 时间数字颜色字段名是 \"timeColor\",不是 \"numberColor\"\n  //   5. 描述文字颜色字段名是 \"descColor\",不是 \"titleColor\"\n  //   6. 不存在 \"countdownType\"、\"endDate\"、\"numberColor\"、\"titleColor\" 等字段,使用这些字段会导致图表渲染异常\n  \"COUNTDOWN\": {\n    // 必填,图表类型,固定值 \"COUNTDOWN\"\n    \"chartType\": \"COUNTDOWN\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"倒计时\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": null,\n    // 可选,描述文本,显示在倒计时数字上方\n    \"desc\": \"倒计时\",\n    // 必填,数据来源类型,合法值:fixed(固定日期时间)/ field(从数据表日期字段取值)\n    \"sourceType\": \"fixed\",\n    // sourceType = \"fixed\" 时必填,目标日期的毫秒时间戳\n    // ⚠️ 必须是毫秒时间戳数字(如 1746028800000),不是日期字符串(如 \"2026-05-01\")!\n    // 示例:2026-05-01 00:00:00 → 1746028800000\n    \"fixedDatetime\": 1746028800000,\n    // sourceType = \"field\" 时填写,日期字段的 fieldId\n    \"fieldId\": null,\n    // sourceType = \"field\" 时填写,日期选择方式,合法值:earliest(最早日期)/ latest(最晚日期)\n    \"fieldChoice\": \"earliest\",\n    // 必填,计时模式,合法值:countdown(倒计时,目标时间在未来)/ countup(正计时,目标时间在过去)\n    \"timingMode\": \"countdown\",\n    // 必填,显示的时间单位开关(是一个对象,不是字符串!)\n    // ⚠️ 不要写成 \"天\" 或 \"day\",必须是如下对象格式:\n    \"unit\": {\n      // 是否显示月,默认 false\n      \"month\": false,\n      // 是否显示周,默认 false\n      \"week\": false,\n      // 是否显示天,默认 true\n      \"day\": true,\n      // 是否显示小时,默认 true\n      \"hour\": true,\n      // 是否显示分钟,默认 true\n      \"minute\": true,\n      // 是否显示秒,默认 true\n      \"second\": true\n    },\n\n    // ── 样式配置 ──────────────────────────────────────────────────────────────\n    // 可选,卡片背景颜色(⚠️ 字段名是 \"background\",不是 \"backgroundColor\")\n    \"background\": \"\",\n    // 可选,描述文字颜色(⚠️ 字段名是 \"descColor\",不是 \"titleColor\")\n    \"descColor\": \"\",\n    // 可选,时间块背景颜色\n    \"timeBlockColor\": \"\",\n    // 可选,倒计时数字颜色(⚠️ 字段名是 \"timeColor\",不是 \"numberColor\")\n    \"timeColor\": \"\",\n    // 可选,计时完成是否显示动效,默认 false\n    \"showFinishEffect\": false\n  },\n// 详情列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"DETAIL_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"DETAIL_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"详情列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置详情列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置详情列表的显示方式和布局\n    \"listConfig\": {},\n    // 工具栏配置,用于控制详情列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n    },\n  },\n// 嵌入网页配置格式,必须按照此格式配置,否则会导致解析错误\n  \"EMBED_WEBPAGE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"EMBED_WEBPAGE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"嵌入网页\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 必填,嵌入的网页 URL(必须是有效的 URL 地址)\n    \"url\": \"https://example.com\"\n  },\n// 漏斗图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"FUNNEL\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"FUNNEL\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"漏斗图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"sumValue\": true,\n    \"funnelType\": \"normal\",\n    \"filter\": []\n  },\n// 画册视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"GALLERY_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"GALLERY_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"画册视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 画册自定义配置,用于设置画册的显示样式\n    \"galleryCustom\": {\n      // 卡片设置,用于配置画册中卡片的显示方式\n      \"cardSetting\": {\n        // 封面模式,合法值有:none(无封面)/ cover(封面)等\n        \"coverMode\": \"none\"\n      }\n    },\n    // 工具栏配置,用于控制画册视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 甘特视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"GANTT_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"GANTT_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"甘特视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 视图分组配置,用于设置视图的默认分组规则\n    \"viewGroup\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 是否折叠网格,默认为 false\n    \"collapsedGrid\": false,\n    // 甘特自定义配置,这是甘特视图的核心配置\n    \"ganttCustom\": {\n      // 必填,显示字段 ID(必须是该表中真实存在的字段 ID)\n      // 用于在甘特图中显示任务名称或描述\n      \"displayFieldId\": \"fld_text_01\",\n      // 必填,开始时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置任务的开始时间\n      \"startField\": \"fld_date_01\",\n      // 必填,结束时间字段 ID(必须是该表中真实存在的日期字段 ID)\n      // 用于设置任务的结束时间\n      \"endField\": \"fld_date_02\",\n      // 标记配置,用于在甘特图中添加里程碑或重要时间点\n      \"markers\": [],\n      // 时间轴刻度,合法值有:day(日)/ week(周)/ month(月)/ quarter(季)/ year(年)\n      // 用于设置甘特图的时间轴显示粒度\n      \"timelineScale\": \"month\",\n      // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\n      \"widthMap\": {},\n      // 单元格高度,单位为像素,默认为 32\n      \"cellHeight\": 32\n    },\n    // 工具栏配置,用于控制甘特视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 表格视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"GRID_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"GRID_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"表格视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 视图分组配置,用于设置视图的默认分组规则\n    \"viewGroup\": [],\n    // 单元格高度,单位为像素,默认为 32\n    \"cellHeight\": 32,\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\n    \"widthMap\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 冻结列数,默认为 1,表示冻结左侧的列数\n    \"frozenColCount\": 1,\n    // 工具栏配置,用于控制表格视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 创建行按钮的文本内容\n      \"createRowText\": \"\",\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:柱状图(HISTOGRAM)\n  // 别名/同义词:竖向柱图、垂直柱图、分组柱图\n  // 用途:以垂直柱子展示多个分类维度的数值对比,适合比较不同类别之间的\n  //       数量差异,如各月销售额对比、各部门人数对比、各产品销量排名等。\n  //       核心特征:有 X 轴(分类维度)和 Y 轴(数值),每个类别对应一根柱子。\n  // 与\"统计数字(SUMMARY)\"的关键区别:\n  //   - 柱状图(HISTOGRAM):展示多个分类的对比,有坐标轴,是\"多值图表\"\n  //   - 统计数字(SUMMARY):只展示一个聚合数字,无坐标轴,是\"单值卡片\"\n  // 重要:当用户说\"柱状图\"、\"柱图\"、\"分组柱图\"时,\n  //       必须使用 chartType = \"HISTOGRAM\",不要使用 SUMMARY 或其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  \"HISTOGRAM\": {\n    // 必填,图表类型,固定值 \"HISTOGRAM\"(柱状图)\n    \"chartType\": \"HISTOGRAM\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"柱状图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"xSortCustom\": [],\n    \"ySortCustom\": [],\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"axisLabelShow\": false,\n    \"label\": true,\n    \"labelHideOverlap\": true,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"xAxisLabelRotate\": \"auto\",\n    \"categoryAxisMaxRatio\": \"0.2\",\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"yAxisRange\": {\n      \"type\": \"absolute\",\n      \"range\": []\n    },\n    \"xAbbr\": {\n      \"numberType\": \"default\",\n      \"abbr\": \"\",\n      \"decimal\": \"\"\n    },\n    \"yAbbr\": {\n      \"numberType\": \"default\",\n      \"abbr\": \"\",\n      \"decimal\": \"\"\n    },\n    \"yRightAbbr\": {\n      \"numberType\": \"default\",\n      \"abbr\": \"\",\n      \"decimal\": \"\"\n    },\n    \"filter\": []\n  }\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:图片(IMAGE)\n  // 别名/同义词:图片卡、图片展示、图片轮播、Banner 图\n  // 用途:在仪表盘中展示一张或多张静态图片,支持多图轮播(滑动/淡入淡出),\n  //       可为每张图片配置标题、描述和点击跳转链接。适合展示品牌 Banner、\n  //       产品图、说明图、装饰图等不依赖数据表的纯图片内容。\n  // 重要:当用户说\"图片\"、\"图片卡\"、\"图片展示\"、\"Banner\"、\"轮播图\"时,\n  //       必须使用 chartType = \"IMAGE\",不要使用其他图表类型。\n  //       此图表类型不依赖数据表,无需配置 sheet / view / filter。\n  // ═══════════════════════════════════════════════════════════════════\n  \"IMAGE\": {\n    // 必填,图表类型,固定值 \"IMAGE\"(图片)\n    \"chartType\": \"IMAGE\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"图片\",\n    // 必填,图片列表,至少包含一张图片;多张图片时支持轮播\n    \"img_images\": [\n      {\n        // 必填,图片的访问 URL\n        \"url\": \"https://example.com/image.png\",\n        // 可选,图片标题,显示在图片上方(需 img_showImgText=true)\n        \"title\": \"\",\n        // 可选,图片描述,显示在标题下方(需 img_showImgText=true)\n        \"desc\": \"\",\n        // 可选,点击图片后跳转的链接 URL\n        \"toUrl\": \"\",\n        // 必填,图片填充模式,合法值:cover(裁剪铺满)/ contain(等比缩放留白)/ fill(拉伸铺满)\n        \"fillMode\": \"cover\"\n      }\n    ],\n    // 可选,是否在图片上显示标题和描述文字,默认 true;\n    // 设为 false 时 img_titleStyle / img_descStyle / img_textAlign 均不生效\n    \"img_showImgText\": true,\n    // 可选,图片文字的对齐位置,合法值:\n    //   leftTop / top / rightTop / left / center / right / leftBottom / bottom / rightBottom\n    \"img_textAlign\": \"center\",\n    // 可选,图片标题的字体样式(仅 img_showImgText=true 时生效)\n    \"img_titleStyle\": {\n      // 字体大小(px),合法范围 16~56,步长 4\n      \"size\": 16,\n      // 字体颜色,十六进制色值,如 \"#FFFFFF\"\n      \"color\": \"\",\n      // 是否加粗\n      \"bold\": true,\n      // 是否斜体\n      \"italic\": false,\n      // 是否下划线\n      \"underline\": false\n    },\n    // 可选,图片描述的字体样式(仅 img_showImgText=true 时生效)\n    \"img_descStyle\": {\n      // 字体大小(px),合法范围 16~56,步长 4\n      \"size\": 14,\n      // 字体颜色,十六进制色值\n      \"color\": \"\",\n      // 是否加粗\n      \"bold\": false,\n      // 是否斜体\n      \"italic\": false,\n      // 是否下划线\n      \"underline\": false\n    },\n    // 可选,多图时是否自动轮播,默认 false;单张图片时此配置无效\n    \"img_autoPlay\": false,\n    // 可选,多图轮播切换动效,合法值:slide(滑动)/ fade(淡入淡出),默认 slide;单张图片时无效\n    \"img_imgTransitionType\": \"slide\",\n    // 可选,多图自动轮播间隔(秒),合法范围 0.1~1000,默认 1;仅 img_autoPlay=true 时生效\n    \"img_imgTransitionInterval\": 1\n  },\n// 看板视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"KANBAN_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"KANBAN_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"看板视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 看板自定义配置,这是看板视图的核心配置\n    \"kanbanCustom\": {\n      // 分组基础配置,用于设置看板的分组字段\n      \"groupBase\": {\n        // 必填,分组字段 ID(必须是该表中真实存在的字段 ID)\n        // 看板将按照此字段的值进行分组显示\n        \"baseFieldId\": \"fld_select_01\"\n      }\n    },\n    // 工具栏配置,用于控制看板视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// 排行榜配置格式,必须按照此格式配置,否则会导致解析错误\n  \"LEADERBOARD\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LEADERBOARD\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"排行榜\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    \"measureType\": \"record-count\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_text_01\"\n      }\n    ],\n    \"showType\": \"podium\", // 展示类型:podium 领奖台/list 列表\n    \"showCount\": 10, // 显示条数\n    \"hideValue\": false,\n    \"sortRule\": \"asc\",\n    \"filter\": []\n  },\n// 折线图配置格式\n  \"LINE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LINE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"折线图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": true,\n    \"labelHideOverlap\": true,\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"filter\": []\n  }\n// 抽奖配置格式,必须按照此格式配置,否则会导致解析错误\n  \"LOTTERY\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"LOTTERY\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"抽奖\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 必填,指标类型,合法值有:record-count(记录数)/ field(字段)\n    \"measureType\": \"record-count\",\n    // 必填,指标配置数组\n    \"measure\": [],\n    // 可选,输出表 ID,用于记录中奖结果\n    \"outputSheet\": \"\",\n    // 可选,滚动速度,默认为 5\n    \"speed\": 5,\n    // 可选,字体大小,默认为 20\n    \"fontSize\": 20,\n    // 可选,颜色主题,合法值有:COLOR_PALETTE_1 到 COLOR_PALETTE_10\n    \"colors\": \"COLOR_PALETTE_1\",\n    // 必填,奖项配置数组,每个元素格式为 [数量, 名称]\n    \"gameRounds\": [\n      [\n        1,\n        \"一等奖\"\n      ]\n    ],\n    // 可选,是否包含已中奖者,默认为 false\n    \"includeWinners\": false\n  },\n// 跑马灯配置格式,必须按照此格式配置,否则会导致解析错误\n  \"MARQUEE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"MARQUEE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"跑马灯\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,滚动文本内容\n    \"content\": \"这是一条滚动消息\",\n    // 可选,字体大小,默认为 14\n    \"fontSize\": 14,\n    // 可选,滚动速度,数值越小速度越快,默认为 50\n    \"speed\": 50,\n    // 可选,字体配置\n    \"fontSetting\": {\n      // 字体颜色\n      \"color\": \"\",\n      // 是否加粗,默认为 false\n      \"bold\": false,\n      // 是否斜体,默认为 false\n      \"italic\": false,\n      // 是否下划线,默认为 false\n      \"underline\": false\n    }\n  },\n// NPS评分配置格式,必须按照此格式配置,否则会导致解析错误\n  \"NPS\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"NPS\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"NPS评分\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\"\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"showLabel\": true,\n    \"showList\": false,\n    \"threshold\": [ // 评分阈值数组\n      2,\n      3\n    ],\n    \"colorSetName\": \"COLOR_PALETTE_1\",\n    \"filter\": []\n  },\n// 百分比堆叠面积图配置格式\n  \"PERCENT_STACKED_AREA\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PERCENT_STACKED_AREA\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"百分比堆叠面积图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 百分比堆叠条形图配置格式\n  \"PERCENT_STACKED_BAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PERCENT_STACKED_BAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"百分比堆叠条形图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"categoryAxisMaxRatio\": \"0.3\",\n    \"filter\": []\n  }\n// 百分比堆叠柱状图配置格式\n  \"PERCENT_STACKED_HISTOGRAM\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PERCENT_STACKED_HISTOGRAM\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"百分比堆叠柱状图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 饼图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"PIE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PIE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"饼图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"colors\": \"COLOR_PALETTE_1\", // 必须使用示例中的默认值如 COLOR_PALETTE_1,禁止自行编造\n    \"legend\": \"none\",\n    \"label\": true, // 必须设为 true 以默认显示数据标签\n    \"innerRadius\": 0, // 内半径,0 为实心饼图\n    \"outerRadius\": 60, // 外半径百分比\n    \"filter\": []\n  },\n// 透视表配置格式,必须按照此格式配置,否则会导致解析错误\n  \"PIVOT\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PIVOT\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"透视表\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 透视表配置,这是透视表的核心配置\n    \"pivotConfig\": {\n      // 必填,rows 是行字段列表,至少要有一个行字段,\n      // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\n      // 如果多层分组,行字段列表中多个字段的 fieldId 依次对应每层分组的字段 ID\n      \"rows\": [\n        {\n          // 必填,行字段 ID(必须是该表中真实存在的字段 ID)\n          \"fld\": \"fld_01\",\n          // 可选,排序方式,合法值有:1(升序)和 -1(降序),默认为 1 升序\n          \"srt\": 1\n        }\n      ],\n      // 可选,columns 是列字段列表,默认为空数组,\n      // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\n      // 如果多层分组,列字段列表中多个字段的 fieldId 依次对应每层分组的字段 ID\n      \"columns\": [\n        {\n          // 必填,列字段 ID(必须是该表中真实存在的字段 ID)\n          \"fld\": \"fld_02\",\n          // 可选,排序方式,合法值有:1(升序)和 -1(降序),默认为 1 升序\n          \"srt\": 1\n        }\n      ],\n      // 必填,values 是值字段列表,至少要有一个值字段,\n      // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\n      // 值字段,是用于计算值的字段,通常是数值字段,但也可以是其他类型的字段(如文本字段),具体取决于聚合函数的要求\n      \"values\": [\n        {\n          // 必填,值字段 ID(必须是该表中真实存在的字段 ID)\n          \"fld\": \"fld_03\",\n          //必填,聚合函数,合法值有:sum /count / count_distinct / avg / min / max / median / population_variance / sample_variance / population_stddev / sample_stddev / first / last / range / mode / sum_of_squares / harmonic_mean / geometric_mean / interquartile_range / centered_mean / weighted_mean / kurtosis / skewness / quartile_median / range_median / coefficient_of_variation / adjusted_stddev / weighted_sum / weighted_count / weighted_count_distinct / weighted_avg / centered_weighted_mean / weighted_harmonic_mean / weighted_geometric_mean / weighted_range / weighted_range_median / weighted_interquartile_range / weighted_quartile_median / weighted_centered_mean / weighted_adjusted_stddev\n          \"agg\": \"sum\",\n          //可选,后聚合函数,合法值有:default / percent_of_row_total / percent_of_col_total / percent_of_grand_total\n          \"pgg\": \"string\",\n          //可选,格式化函数,合法值有:default / percentage / thousands / currency / scientific / fixed / integer\n          \"fmt\": \"\",\n          //可选,字段别名\n          \"als\": \"string\"\n        }\n      ],\n      // 是否显示行总计,默认为 true\n      \"rowTotals\": true,\n      // 是否显示列总计,默认为 true\n      \"columnTotals\": true\n    },\n    \"pivotStyle\": {\n      // 可选,透视表在卡片中的对齐方式,合法值有: start(上部)/ center(中间)\n      \"verticalAlign\": \"start\"\n    }\n  },\n// 地图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"PLOT_MAP\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"PLOT_MAP\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"地图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 地图配置,这是地图的核心配置\n    \"plotMapConfig\": {\n      // 数据源配置\n      \"source\": {\n        // 数据源类型,合法值有:view(视图)\n        \"type\": \"view\",\n        // 数据源名称,格式为 \"sheetId:viewId\"\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"sheet_001:view_001\"\n      },\n      // 渲染器类型,合法值有:marker(标记点)/ heatmap(热力图)等\n      // 用于设置地图的渲染方式\n      \"renderer\": \"marker\",\n      // 处理器,用于对数据进行预处理\n      \"processor\": \"\",\n      // 维度配置,用于设置地图的维度字段(如地理位置字段)\n      \"dimensions\": [\n        {\n          // 必填,维度字段 ID(必须是该表中真实存在的字段 ID)\n          // 通常为位置字段,用于在地图上定位\n          \"fieldId\": \"fld_location_01\"\n        }\n      ],\n      // 度量配置,用于设置地图的度量字段(如数值字段)\n      \"measures\": [\n        {\n          // 必填,度量字段 ID(必须是该表中真实存在的字段 ID)\n          // 用于在地图上显示数值大小或颜色\n          \"fieldId\": \"fld_number_01\"\n        }\n      ],\n      // 样式配置,用于设置地图的显示样式\n      \"styles\": []\n    },\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:进度图(PROGRESS)\n  // 别名/同义词:进度条、进度环、进度卡、进度图表\n  // 用途:用于展示某项目标的完成进度,支持当前值 vs 目标值的对比,\n  //       可视化呈现完成百分比。适合展示销售目标达成率、任务完成度、\n  //       日期时间进度等场景。\n  // 重要:当用户说\"进度图\"、\"进度条\"、\"进度环\"、\"目标完成度\"时,\n  //       必须使用 chartType = \"PROGRESS\",不要使用其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  //\n  // ⚠️⚠️⚠️ 极其重要的注意事项(不遵守会导致图表渲染报错):\n  //\n  //   1. currentMeasure / targetMeasure 的格式是一个对象(Object),绝对不是数组(Array)!\n  //      不要使用普通图表的 measure 数组格式 [{ externalValue, value }],否则图表会报错!\n  //\n  //   2. 必须包含以下顶层通用字段,否则图表校验会失败并报「表格字段发生变化」错误:\n  //      - \"measureType\": \"record-count\"  ← 必填!缺少此字段图表必定报错\n  //      - \"sheet\": \"\u003csheetId\u003e\"           ← 必填!设置为 currentSheet 相同的值\n  //      - \"view\": \"\"                     ← 必填!设置为空字符串\n  //      - \"filter\": []                   ← 必填!设置为空数组\n  //\n  //   3. currentFilter / targetFilter 中的每个筛选条件必须包含 \"link\" 字段:\n  //      正确:{ \"fieldId\": \"xxx\", \"symbol\": \"EQ\", \"value\": \"yyy\", \"link\": \"AND\" }\n  //      错误:{ \"fieldId\": \"xxx\", \"symbol\": \"EQ\", \"value\": \"yyy\" }  ← 缺少 link!\n  //      注意:单选字段的 value 必须是选项的 ID(如 \"PO5PWsAqQi\"),不能是选项的显示文本(如 \"影院观影\")!\n  //      使用文本作为 value 会导致筛选条件无法匹配任何记录,当前值始终为 0。\n  //\n  //   4. 即使 targetSource 为 \"custom\",也必须提供 targetSheet、targetView、targetFilter、targetMeasure 的默认值\n  //      即使 currentSource 为 \"custom\",也必须提供 currentSheet、currentView、currentFilter、currentMeasure 的默认值\n  //\n  //   5. 必须包含 numberFormat 和 fixedCount 字段,否则当前值/目标值会显示异常(数值被乘以100、出现双%%、文案被挤掉不显示):\n  //      - \"numberFormat\": { \"digits\": 0, \"format\": \"number\", \"thousandSeparator\": true }  ← format 必须是 \"number\"!\n  //      - \"fixedCount\": 0\n  //\n  \"PROGRESS\": {\n    // ── 顶层通用字段(必填,缺少会导致图表校验失败)─────────────────────────\n    // 必填,图表类型,固定值 \"PROGRESS\"(进度图)\n    \"chartType\": \"PROGRESS\",\n    // ⚠️ 必填,统计类型,进度图固定为 \"record-count\"\n    \"measureType\": \"record-count\",\n    // ⚠️ 必填,顶层数据表 ID,设置为 currentSheet 相同的值\n    \"sheet\": \"\u003csheetId\u003e\",\n    // ⚠️ 必填,顶层视图 ID,设置为空字符串\n    \"view\": \"\",\n    // ⚠️ 必填,顶层筛选条件,设置为空数组\n    \"filter\": [],\n\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"进度图\",\n    // 必填,数据类型,合法值:number(数值进度)/ date(日期进度)\n    \"dataType\": \"number\",\n    // 必填,图表形状,合法值:bar(条形)/ semi-circle(半环)/ circle(圆环)\n    \"progressType\": \"bar\",\n\n    // ── 目标值配置 ──────────────────────────────────────────────────────────\n    // 目标值来源,合法值:custom(自定义数值)/ sheet(从数据表字段聚合)\n    \"targetSource\": \"custom\",\n    // targetSource = \"custom\" 时填写,目标值的固定数值字符串\n    \"target\": \"500\",\n    // 必填,目标值来源的数据表 ID,即使 targetSource = \"custom\" 也要填写,设置为 currentSheet 相同的值\n    \"targetSheet\": \"\u003csheetId\u003e\",\n    // 必填,目标值来源的视图 ID,设置为空字符串\n    \"targetView\": \"\",\n    // 必填,目标值来源的过滤条件,targetSource = \"custom\" 时设置为空数组\n    \"targetFilter\": [],\n    // 必填,目标值聚合字段配置,targetSource = \"custom\" 时设置为默认值\n    // ⚠️ 格式必须是对象,不是数组!详见下方 currentMeasure 的格式说明,targetMeasure 格式完全相同\n    \"targetMeasure\": { \"type\": \"record-count\", \"fields\": [] },\n    // 可选,目标值的标签文字,显示在目标值旁边\n    \"targetText\": \"\",\n\n    // ── 当前值配置 ──────────────────────────────────────────────────────────\n    // 当前值来源,合法值:custom(自定义数值)/ sheet(从数据表字段聚合)\n    \"currentSource\": \"custom\",\n    // currentSource = \"custom\" 时填写,当前值的固定数值字符串\n    \"current\": \"100\",\n    // 必填,当前值来源的数据表 ID\n    \"currentSheet\": \"\u003csheetId\u003e\",\n    // 必填,当前值来源的视图 ID,设置为空字符串表示全部数据\n    \"currentView\": \"\",\n    // 必填,当前值来源的过滤条件,currentSource = \"custom\" 时设置为空数组\n    // ⚠️ 每个筛选条件必须包含 \"link\" 字段!格式:\n    //   { \"fieldId\": \"\u003cfieldId\u003e\", \"symbol\": \"EQ\", \"value\": \"\u003c选项ID\u003e\", \"link\": \"AND\" }\n    //   多个条件时,第一个条件的 link 也必须填 \"AND\"\n    \"currentFilter\": [],\n    // 必填,当前值聚合字段配置\n    // ═══════════════════════════════════════════════════════════════════\n    // ⚠️⚠️⚠️ 极其重要:currentMeasure 是一个 Object,不是 Array!\n    // 绝对不要写成 [{ externalValue: [...], value: \"xxx\" }] 这种数组格式!\n    // 正确格式如下:\n    //\n    // 方式一:记录计数(统计满足条件的记录总数)\n    //   { \"type\": \"record-count\", \"fields\": [] }\n    //\n    // 方式二:字段聚合(对某个字段做聚合计算)\n    //   { \"type\": \"field\", \"fields\": [{ \"value\": \"\u003cfieldId\u003e\", \"formula\": \"\u003c聚合函数\u003e\" }] }\n    //   formula 合法值:count / unique-count / sum / average / max / min\n    //\n    // 错误示例(绝对禁止):\n    //   [{ \"externalValue\": [{ \"type\": \"formula\", \"value\": \"count\" }], \"value\": \"fieldId\" }]\n    // ═══════════════════════════════════════════════════════════════════\n    \"currentMeasure\": { \"type\": \"record-count\", \"fields\": [] },\n    // 可选,当前值的标签文字,显示在当前值旁边\n    \"currentText\": \"\",\n\n    // ── 数值格式配置 ──────────────────────────────────────────────────────────\n    // ⚠️ 必填,百分比小数位数,缺少会导致小数位数异常,默认填 0\n    \"fixedCount\": 0,\n    // ⚠️ 必填,数字格式化配置(作用于当前值和目标值的显示)\n    // 缺少此字段会导致数值显示异常(数值被乘以100、出现双%%符号、文案被挤掉不显示)\n    \"numberFormat\": {\n      // 小数位数,默认 0\n      \"digits\": 0,\n      // ⚠️ 格式类型,进度图必须填 \"number\"!绝对不要填 \"percent\",否则数值会被乘以100并多出%符号\n      // 合法值:number / percent / thousand,进度图固定填 \"number\"\n      \"format\": \"number\",\n      // 是否显示千位分隔符\n      \"thousandSeparator\": true\n    },\n    // 可选,数值单位,如 \"¥\" / \"$\" / \"人\" / \"%\" 等自定义字符串,不填则无单位\n    \"modifier\": \"\",\n    // 可选,单位位置,合法值:left(左)/ right(右),默认 left\n    \"modifierPosition\": \"left\",\n    // 可选,目标方向,合法值:increase(越大越好)/ decrease(越小越好),影响进度条配色逻辑\n    \"targetDirection\": \"increase\",\n\n    // ── 日期进度(dataType = \"date\")专属配置 ────────────────────────────────\n    // 目标日期范围,合法值:month(本月)/ quarter(本季度)/ year(今年)/ custom(自定义)\n    \"targetDateSelected\": \"month\",\n    // targetDateSelected = \"custom\" 时填写,自定义目标日期范围 [开始时间戳, 结束时间戳]\n    \"targetDateRange\": null,\n    // 当前日期,合法值:today(今日)/ yesterday(昨日)/ custom(自定义)\n    \"currentDateSelected\": \"today\",\n    // currentDateSelected = \"custom\" 时填写,自定义当前日期时间戳\n    \"currentDate\": null,\n    // 日期格式,如 \"YYYY-MM-DD\",仅在 targetDateSelected 或 currentDateSelected 为 custom 时生效\n    \"dateFormat\": \"YYYY-MM-DD\",\n\n    // ── 样式配置 ──────────────────────────────────────────────────────────────\n    // 可选,卡片背景颜色,十六进制色值,如 \"#F0F5FF\"\n    \"background\": \"\",\n    // 可选,百分比数字颜色,十六进制色值,如 \"#1677FF\"\n    \"percentageColor\": \"\",\n    // 可选,进度条填充颜色,支持两种形式:\n    //   1. 固定色值字符串,如 \"#1677FF\",或渐变色 \"linear-gradient(...)\"(圆环图支持渐变)\n    //   2. 条件配色对象(根据百分比区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\n    \"strokeColor\": \"\",\n    // 可选,进度条背景颜色\n    \"strokeBgColor\": \"\",\n    // 可选,当前值文字颜色(仅 dataType=number 时生效)\n    \"currentColor\": \"\",\n    // 可选,目标值文字颜色\n    \"targetColor\": \"\"\n  },\n// 查询页配置格式,必须按照此格式配置,否则会导致解析错误\n  \"QUERY_PAGE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"QUERY_PAGE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"查询页\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"\",\n    // 查询页无其他特殊配置字段,\n    // 主要用于提供数据查询和展示的基础功能\n  },\n// 雷达图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"RADAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"RADAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"雷达图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"xSortBy\": \"y\",\n    \"sortRule\": \"asc\",\n    \"radarShape\": \"circle\",\n    \"isFilled\": true,\n    \"gridShow\": true,\n    \"yAxisLine\": true,\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": true,\n    \"filter\": []\n  },\n// 富文本配置格式,必须按照此格式配置,否则会导致解析错误\n  \"RICH_TEXT\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"RICH_TEXT\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"富文本\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,富文本内容,支持 HTML 格式\n    \"content\": \"\"\n  },\n// 玫瑰图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"ROSE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"ROSE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"玫瑰图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"none\",\n    \"label\": true,\n    \"innerRadius\": 0,\n    \"outerRadius\": 60,\n    \"filter\": []\n  },\n// 散点图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"SCATTER\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"SCATTER\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"散点图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_number_02\",\n        \"externalValue\": []\n      }\n    ],\n    \"smooth\": false,\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"gridShow\": false,\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"filter\": []\n  },\n// 筛选器配置格式,必须按照此格式配置,否则会导致解析错误\n  \"SLICER\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"SLICER\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"筛选器\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,筛选器样式,合法值有:button(按钮)/ dropdown(下拉),默认为 button\n    \"sli_filterStyle\": \"button\",\n    // 可选,排序方式,合法值有:asc(升序)/ desc(降序),默认为 asc\n    \"sli_orderBy\": \"asc\",\n    // 可选,是否多选,默认为 true\n    \"sli_multiple\": true,\n    // 可选,是否显示数量,默认为 true\n    \"sli_showCount\": true,\n    // 可选,日期操作符,合法值有:BEFORE / AFTER / BETWEEN 等\n    \"sli_dateOperator\": \"BEFORE\",\n    // 可选,数字操作符,合法值有:EQ(等于)/ NE(不等于)/ GT(大于)等\n    \"sli_numberOperator\": \"EQ\",\n    // 可选,关联到图表的方式,合法值有:auto(自动)/ manual(手动),默认为 auto\n    \"sli_2Chart\": \"auto\",\n    // 可选,关联到其他筛选器的方式,合法值有:auto(自动)/ manual(手动),默认为 auto\n    \"sli_2Slicer\": \"auto\",\n    // 可选,对齐方式,合法值有:top(顶部)/ bottom(底部),默认为 top\n    \"sli_align\": \"top\",\n    // 可选,选择样式,合法值有:1 / 2 / 3 等,默认为 1\n    \"sli_selectStyle\": \"1\",\n    // 必填,绑定的字段 ID(必须是该表中真实存在的字段 ID)\n    \"sli_fieldId\": \"fld_select_01\"\n  },\n// 堆叠面积图配置格式\n  \"STACKED_AREA\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_AREA\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠面积图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 堆叠条形图配置格式\n  \"STACKED_BAR\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_BAR\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠条形图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_select_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_02\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"bottom\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"categoryAxisMaxRatio\": \"0.3\",\n    \"filter\": []\n  }\n// 堆叠柱状图配置格式\n  \"STACKED_HISTOGRAM\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_HISTOGRAM\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠柱状图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"xSortBy\": \"x\",\n    \"sortRule\": \"asc\",\n    \"nullValue\": \"ignore\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"bottom\",\n    \"gridShow\": false,\n    \"label\": false,\n    \"labelHideOverlap\": true,\n    \"xAxisShow\": true,\n    \"xAxisLine\": false,\n    \"xAxisLabel\": true,\n    \"yAxisShow\": true,\n    \"yAxisLine\": false,\n    \"yAxisLabel\": true,\n    \"fillType\": \"solid\",\n    \"filter\": []\n  }\n// 堆叠折线图配置格式\n  \"STACKED_LINE\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STACKED_LINE\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"堆叠折线图\",\n    \"sheet\": \"sheet_001\",\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_date_01\",\n        \"externalValue\": []\n      }\n    ],\n    \"group\": [\n      {\n        \"value\": \"fld_select_01\"\n      }\n    ],\n    \"smooth\": false,\n    \"symbolType\": \"always\",\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"legend\": \"top\",\n    \"label\": false,\n    \"xAxisShow\": true,\n    \"yAxisShow\": true,\n    \"filter\": []\n  }\n// 标准列表视图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"STANDARD_LIST_VIEW\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"STANDARD_LIST_VIEW\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标准列表视图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\n    \"view\": \"view_001\",\n    // 无 filter 时请传空数组,\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 视图排序配置,用于设置视图的默认排序规则\n    \"viewSort\": [],\n    // 隐藏字段配置,key 为 fieldId,value 为 true/false\n    \"hiddenFields\": {},\n    // 列排序配置,用于设置列的显示顺序\n    \"columnSort\": [],\n    // 列表自定义配置,用于设置列表的自定义样式和行为\n    \"listCustom\": {},\n    // 列表配置,用于设置列表的显示方式和布局\n    \"listConfig\": {},\n    // 字段聚合映射,用于设置字段的聚合方式\n    \"fieldAggregateMap\": {},\n    // 工具栏配置,用于控制标准列表视图中工具栏的功能按钮\n    \"toolbarConfig\": {\n      // 是否显示创建行按钮\n      \"enableCreateRow\": false,\n      // 是否显示筛选按钮\n      \"enableFilter\": true,\n      // 是否显示分组按钮\n      \"enableGroup\": true,\n      // 是否显示排序按钮\n      \"enableSort\": true,\n      // 是否显示查找按钮\n      \"enableFind\": true,\n      // 是否显示详情记录按钮\n      \"enableDetailRecord\": true,\n      // 是否显示编辑记录按钮\n      \"enableEditRecord\": false\n    },\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:指标卡(STATISTICS)\n  // 别名/同义词:KPI 卡、指标图、数据指标卡、关键指标卡\n  // 用途:以大号数字展示核心指标,并可附加环同比(与上期对比的涨跌幅)\n  //       和趋势折线图,适合展示销售额、DAU、转化率等需要对比分析的 KPI 指标。\n  //       与\"统计数字(SUMMARY)\"的区别:指标卡支持环同比对比和趋势图,\n  //       功能更丰富;统计数字仅展示单一数字,更简洁。\n  // 重要:当用户说\"指标卡\"、\"KPI 卡\"、\"指标图\"、\"关键指标\"、\"带趋势的数字\"时,\n  //       必须使用 chartType = \"STATISTICS\",不要使用其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  \"STATISTICS\": {\n    // 必填,图表类型,固定值 \"STATISTICS\"(指标卡)\n    \"chartType\": \"STATISTICS\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"指标卡\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时为 null,传入时必须是该表中真实存在的 viewId\n    \"view\": null,\n    // 无 filter 时请传空数组;\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置)中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 统计指标配置,measureType 与 measure 二选一:\n    //   measureType = \"record-count\" 时统计记录总数,无需填 measure;\n    //   measureType = \"field\" 时需填 measure,指定聚合字段和聚合函数\n    \"measureType\": \"field\",\n    // 可选,当 measureType = \"field\" 时必填,指定聚合字段\n    \"measure\": [\n      {\n        // 必填,字段 ID(必须是该表中真实存在的数值类字段 ID)\n        \"value\": \"fld_number_01\",\n        // 必填,聚合函数配置\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            // formula 合法值:count (计数), unique-count (去重计数), sum (求和), average (平均值), max (最大值), min (最小值)'\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    // 可选,指标说明文字,显示在数值下方\n    \"desc\": \"\",\n    // 可选,数字格式化配置\n    \"numberFormat\": {\n      // 小数位数,默认 0\n      \"digits\": 0,\n      // 格式类型,合法值:number / percent / thousand\n      \"format\": \"number\",\n      // 是否显示千位分隔符\n      \"thousandSeparator\": true\n    },\n    // 可选,是否显示环同比,默认 false\n    \"showComparison\": false,\n    // 可选,showComparison=true 时填写,环同比对比的日期字段 ID\n    \"comparisonField\": null,\n    // 可选,环同比时间范围,如 \"this_week\" / \"this_month\" / \"this_year\"\n    \"comparisonDateRange\": null,\n    // 可选,环同比类型,合法值:sequential(环比)/ year-on-year(同比)/ month-on-month(月比)/ week-on-week(周比)/ custom(自定义)\n    \"comparisonType\": \"sequential\",\n    // 可选,时间统计方式,合法值:same_period(同期)/ cumulative(累计)\n    \"comparisonPeriodType\": \"same_period\",\n    // 可选,环同比计算类型,合法值:difference-rate(差值率)/ difference-value(差值)/ original-value(原值)\n    \"comparisonCalculationType\": \"difference-rate\",\n    // 可选,环同比布局方向(仅 showComparison=true 且 showTrend=false 时生效),合法值:bottom(下方)/ right(右侧)\n    \"comparisonLayout\": \"bottom\",\n    // 可选,是否显示趋势图,默认 false\n    \"showTrend\": false,\n    // 可选,showTrend=true 时填写,趋势图使用的日期字段 ID\n    \"trendField\": null,\n    // 可选,趋势图时间范围,如 \"this_week\" / \"this_month\" / \"this_year\"\n    \"trendDateRange\": null,\n    // 可选,趋势图布局方向(仅 showTrend=true 且 showComparison=false 时生效),合法值:bottom(下方)/ right(右侧)\n    \"trendLayout\": \"bottom\",\n    // 可选,趋势图颜色,十六进制色值,如 \"#1677FF\"\n    \"trendColor\": \"\",\n    // 可选,环同比图标样式,合法值:1 / 2 / 3 / 4 / 5 / 6 / 7 / 8\n    \"iconStyle\": \"5\",\n    // 可选,卡片标题颜色,十六进制色值\n    \"titleColor\": \"\",\n    // 可选,指标说明文字颜色,十六进制色值\n    \"descColor\": \"\",\n    // 可选,环同比描述颜色,十六进制色值\n    \"comparisonDescColor\": \"\",\n    // 可选,数值颜色配置,支持两种形式:\n    //   1. 固定色值字符串,如 \"#1677FF\",或渐变色 \"linear-gradient(...)\"\n    //   2. 条件配色对象(根据数值区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\n    \"colors\": \"\"\n  },\n// ═══════════════════════════════════════════════════════════════════\n  // 图表类型:统计数字(SUMMARY)\n  // 别名/同义词:汇总数字、统计卡、数字卡、计数卡\n  // 用途:以大号数字形式展示单一聚合指标(如记录总数、字段求和/均值等),\n  //       是最简洁的数据汇总展示方式,适合展示总销售额、总订单数、平均分等。\n  // 与其他图表的关键区别:\n  //   - 与\"指标卡(STATISTICS)\"的区别:统计数字更简洁,无环同比、无趋势图;\n  //     指标卡功能更丰富,支持环同比对比和趋势折线图。\n  //   - 与\"柱状图(HISTOGRAM)\"的关键区别:统计数字只展示一个聚合数字(单值卡片),\n  //     无坐标轴、无分类对比;柱状图展示多个分类的数值对比,有 X/Y 轴(多值图表)。\n  // 重要:当用户说\"统计数字\"、\"汇总数字\"、\"数字卡\"、\"计数\"、\"总数\"时,\n  //       必须使用 chartType = \"SUMMARY\",不要使用 HISTOGRAM 或其他图表类型。\n  // ═══════════════════════════════════════════════════════════════════\n  \"SUMMARY\": {\n    // 必填,图表类型,固定值 \"SUMMARY\"(统计数字)\n    \"chartType\": \"SUMMARY\",\n    // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\n    \"name\": \"统计数字\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时为 null,传入时必须是该表中真实存在的 viewId\n    \"view\": null,\n    // 无 filter 时请传空数组;\n    // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置)中 filters 的 filter 格式,\n    // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\n    \"filter\": [],\n    // 统计指标配置,measureType 与 measure 二选一:\n    //   measureType = \"record-count\" 时统计记录总数,无需填 measure;\n    //   measureType = \"field\" 时需填 measure,指定聚合字段和聚合函数\n    \"measureType\": \"field\",\n    // 可选,当 measureType = \"field\" 时必填,指定聚合字段\n    \"measure\": [\n      {\n        // 必填,字段 ID(必须是该表中真实存在的数值类字段 ID)\n        \"value\": \"fld_number_01\",\n        // 必填,聚合函数配置\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            // formula 合法值:count (计数), unique-count (去重计数), sum (求和), average (平均值), max (最大值), min (最小值)'\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    // 可选,数字格式化配置\n    \"numberFormat\": {\n      // 小数位数,默认 0\n      \"digits\": 0,\n      // 格式类型,合法值:number / percent / thousand\n      \"format\": \"number\",\n      // 是否显示千位分隔符\n      \"thousandSeparator\": true\n    },\n    // 可选,指标说明文字,由用户在编辑器中输入,传给服务端后显示在数值下方\n    \"desc\": \"\",\n    // 可选,数值颜色配置,支持两种形式:\n    //   1. 固定色值字符串,如 \"#1677FF\",或渐变色 \"linear-gradient(...)\"\n    //   2. 条件配色对象(根据数值区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\n    \"colors\": \"\"\n  },\n// 标签页配置格式,必须按照此格式配置,否则会导致解析错误\n  \"TABS\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"TABS\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    // 可选,标签页样式类型,合法值有:type1 到 type10,默认为 type1\n    \"tabsStyleType\": \"type1\",\n    // 可选,标签页对齐方式,合法值有:left(左对齐)/ center(居中)/ right(右对齐),默认为 left\n    \"tabsAlignType\": \"left\",\n    // 可选,默认激活的标签页 ID,必须与 tabs 数组中的某个 id 匹配\n    \"activeTabId\": \"tab_001\",\n    // 必填,标签页数组\n    \"tabs\": [\n      {\n        // 必填,标签页 ID(必须唯一)\n        \"id\": \"tab_001\",\n        // 必填,标签页名称\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页 1\"\n      },\n      {\n        \"id\": \"tab_002\",\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页 2\"\n      },\n      {\n        \"id\": \"tab_003\",\n        // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"标签页 3\"\n      }\n    ]\n  },\n// 词云图配置格式,必须按照此格式配置,否则会导致解析错误\n  \"WORD_CLOUD\": {\n    // 必填,图表的类型,不传或传错图表将渲染异常\n    \"chartType\": \"WORD_CLOUD\",\n    // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\n    \"name\": \"词云图\",\n    // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\n    \"sheet\": \"sheet_001\",\n    // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\n    \"view\": \"view_001\",\n    \"measureType\": \"field\",\n    \"measure\": [\n      {\n        \"value\": \"fld_number_01\",\n        \"externalValue\": [\n          {\n            \"type\": \"formula\",\n            \"value\": \"sum\"\n          }\n        ]\n      }\n    ],\n    \"dimension\": [\n      {\n        \"value\": \"fld_text_01\"\n      }\n    ],\n    \"disableParticiple\": false, // 是否禁用分词\n    \"colors\": \"COLOR_PALETTE_1\",\n    \"filter\": []\n  },\n}\n",
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Dashboard widgets config example returned. Each key is a chart type (e.g. HISTOGRAM, PIE), and the value is the corresponding widget config example. Use the matching chart type's config as a reference when calling create_chart."
-}
dws aitable +dashboard-arrange --base-id <base-id> --dashboard-id <dashboard-id>write
对指定仪表盘做服务端智能布局重排
dws mcp aitable align_dashboard --json '{"baseId":<..>, "dashboardId":<..>}'lark-cli base +dashboard-arrange已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/align_dashboard
输入 / CLI 输出
输入
dws aitable +dashboard-arrange --base-id x --dashboard-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "align_dashboard"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "align_dashboard"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1141ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +dashboard-arrange --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to align dashboard for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998564478222e08d8\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to align dashboard for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998564478222e08d8\"}"
-  }
-}
dws aitable +dashboard-config-exampleread
获取 dashboard config 的结构示例
dws mcp aitable get_dashboard_config_examplelark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_dashboard_config_example
输入 / CLI 输出
输入
dws aitable +dashboard-config-example --yes
stdout
{
-  "_fake": true,
-  "args": {},
-  "product": "aitable",
-  "tool": "get_dashboard_config_example"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {},
-  "product": "aitable",
-  "tool": "get_dashboard_config_example"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1064ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +dashboard-config-example --yes --format json
真实 stdout
{
-  "data": "// 注意\n// 1. 下述 JSON 示例中的注释(// 开头的注释)仅用于说明,实际使用时请删除,否则会导致解析错误\n// 2. 当一个仪表盘创建完成,并添加图表完成后,检查一下布局,如果有空白或不对齐的图表,请调用 update_chart 优化布局\n// 3. 当一个仪表盘完整创建后,包括图表也添加完成后,可通过 get_dashboard 接口查看仪表盘详情,检查布局是合适(没有空白或不对齐),如果有问题,调用 update_chart 优化布局\n// 4. 仪表盘创建好并优化布局后,向用户输出仪表盘链接 URL(注意是仪表盘 URL),方便用户直接访问和使用仪表盘\n\n{\n  // 服务端自动生成,调用方传入会被忽略\n  \"id\": \"dashboard_001\",\n  // 必填,dashboard 名称,是整个仪表盘的标题,一定要传入,且清晰明确,方便用户使用\n  \"name\": \"示例仪表盘\",\n  // 必须使用本示例中的默认主题,禁止自行编造,随意生成的主题无法解析和渲染\n  \"theme\": {\n    // 必须为 DEFAULT\n    \"key\": \"DEFAULT\",\n    // 主题背景色,大部分情况下「传入 null 或 undefined」,使用默认即可\n    \"background\": null,\n    \"image\": \"\"\n  },\n  // 可选,全局筛选条件数组;每个元素对应一张数据表的筛选规则\n  \"filters\": [\n    {\n      // 要筛选的数据表 ID(必须是真实存在的 tableId)\n      \"sheetId\": \"sheet_001\",\n      // 筛选条件数组;第一个条件的 link 必须为 null,后续条件用 \"AND\" 或 \"OR\" 连接\n      \"filter\": [\n        {\n          // 要筛选的字段 ID(必须是该表中真实存在的 fieldId)\n          \"fieldId\": \"fld_001\",\n          // 条件连接符:第一个条件必须为 null,后续条件为 \"AND\" 或 \"OR\"\n          \"link\": null,\n          // 固定为 1\n          \"version\": 1,\n          // 比较符号,合法值按字段类型分组如下:\n          //   文本类: EQ(等于), NE(不等于), CONTAIN(包含), EXCLUSIVE(不包含) — value 为字符串\n          //   数字类: EQ, NE, GT(大于), LT(小于), GTE(大于等于), LTE(小于等于) — value 为数字\n          //   日期类: DATE_EQ(日期等于), BEFORE(早于), AFTER(晚于), NOT_AFTER(不晚于), NOT_BEFORE(不早于) — value 为 \"yyyy-MM-dd\" 格式字符串\n          //   选项类: ANY_OF(包含任一), ALL_OF(包含全部), NONE_OF(不包含任一) — value 为选项 ID 字符串数组,如 [\"opt_001\", \"opt_002\"]\n          //   存在性: EXIST(有值), UN_EXIST(无值) — value 为 null\n          \"symbol\": \"EQ\",\n          // value 的类型取决于 symbol,参见上方说明\n          \"value\": \"已完成\"\n        }\n      ]\n    }\n  ],\n  // 图表布局数组;创建时会被忽略(默认空数组),更新时有效\n  // 每个元素描述一个图表在网格中的位置和尺寸\n  // layout 的更新,需要通过 update_chart 接口更新到每个图表上,update_chart 接口会自动根据 layout 调整仪表盘布局\n  // 无法通过 update_dashboard 接口直接更新布局,必须通过 update_chart 接口更新图表来间接更新布局\n  \"layout\": [\n    {\n      // 对应的 widget/chart ID\n      \"i\": \"widget_001\",\n      // 横坐标(列位置,从 0 开始,取值范围 0~11)\n      \"x\": 0,\n      // 纵坐标(行位置,从 0 开始,无上限)\n      \"y\": 0,\n      // 宽度(占几列,网格总宽 12 列,取值范围 1~12,同一行所有图表的 w 之和应等于 12)\n      \"w\": 12,\n      // 高度(占几行,取值范围 1~N,同一行的图表必须保持相同高度)\n      \"h\": 6,\n      // 最小宽度(可选,限制用户拖拽缩放的下限)\n      \"minW\": 4,\n      // 最小高度(可选,限制用户拖拽缩放的下限)\n      \"minH\": 3,\n      // 可选,所属分组 ID(用于 COMPOSITE_LAYOUT 组合布局,不使用分组时省略)\n      \"parentId\": \"group_001\"\n    }\n  ]\n}",
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Dashboard config example returned. The 'id' field is for structure reference only; it will be auto-generated by the server when creating a dashboard. The 'layout' field is ignored when creating (defaults to empty array), but effective when updating."
-}
真实 stderr / error
(空)
真实返回
{
-  "data": "// 注意\n// 1. 下述 JSON 示例中的注释(// 开头的注释)仅用于说明,实际使用时请删除,否则会导致解析错误\n// 2. 当一个仪表盘创建完成,并添加图表完成后,检查一下布局,如果有空白或不对齐的图表,请调用 update_chart 优化布局\n// 3. 当一个仪表盘完整创建后,包括图表也添加完成后,可通过 get_dashboard 接口查看仪表盘详情,检查布局是合适(没有空白或不对齐),如果有问题,调用 update_chart 优化布局\n// 4. 仪表盘创建好并优化布局后,向用户输出仪表盘链接 URL(注意是仪表盘 URL),方便用户直接访问和使用仪表盘\n\n{\n  // 服务端自动生成,调用方传入会被忽略\n  \"id\": \"dashboard_001\",\n  // 必填,dashboard 名称,是整个仪表盘的标题,一定要传入,且清晰明确,方便用户使用\n  \"name\": \"示例仪表盘\",\n  // 必须使用本示例中的默认主题,禁止自行编造,随意生成的主题无法解析和渲染\n  \"theme\": {\n    // 必须为 DEFAULT\n    \"key\": \"DEFAULT\",\n    // 主题背景色,大部分情况下「传入 null 或 undefined」,使用默认即可\n    \"background\": null,\n    \"image\": \"\"\n  },\n  // 可选,全局筛选条件数组;每个元素对应一张数据表的筛选规则\n  \"filters\": [\n    {\n      // 要筛选的数据表 ID(必须是真实存在的 tableId)\n      \"sheetId\": \"sheet_001\",\n      // 筛选条件数组;第一个条件的 link 必须为 null,后续条件用 \"AND\" 或 \"OR\" 连接\n      \"filter\": [\n        {\n          // 要筛选的字段 ID(必须是该表中真实存在的 fieldId)\n          \"fieldId\": \"fld_001\",\n          // 条件连接符:第一个条件必须为 null,后续条件为 \"AND\" 或 \"OR\"\n          \"link\": null,\n          // 固定为 1\n          \"version\": 1,\n          // 比较符号,合法值按字段类型分组如下:\n          //   文本类: EQ(等于), NE(不等于), CONTAIN(包含), EXCLUSIVE(不包含) — value 为字符串\n          //   数字类: EQ, NE, GT(大于), LT(小于), GTE(大于等于), LTE(小于等于) — value 为数字\n          //   日期类: DATE_EQ(日期等于), BEFORE(早于), AFTER(晚于), NOT_AFTER(不晚于), NOT_BEFORE(不早于) — value 为 \"yyyy-MM-dd\" 格式字符串\n          //   选项类: ANY_OF(包含任一), ALL_OF(包含全部), NONE_OF(不包含任一) — value 为选项 ID 字符串数组,如 [\"opt_001\", \"opt_002\"]\n          //   存在性: EXIST(有值), UN_EXIST(无值) — value 为 null\n          \"symbol\": \"EQ\",\n          // value 的类型取决于 symbol,参见上方说明\n          \"value\": \"已完成\"\n        }\n      ]\n    }\n  ],\n  // 图表布局数组;创建时会被忽略(默认空数组),更新时有效\n  // 每个元素描述一个图表在网格中的位置和尺寸\n  // layout 的更新,需要通过 update_chart 接口更新到每个图表上,update_chart 接口会自动根据 layout 调整仪表盘布局\n  // 无法通过 update_dashboard 接口直接更新布局,必须通过 update_chart 接口更新图表来间接更新布局\n  \"layout\": [\n    {\n      // 对应的 widget/chart ID\n      \"i\": \"widget_001\",\n      // 横坐标(列位置,从 0 开始,取值范围 0~11)\n      \"x\": 0,\n      // 纵坐标(行位置,从 0 开始,无上限)\n      \"y\": 0,\n      // 宽度(占几列,网格总宽 12 列,取值范围 1~12,同一行所有图表的 w 之和应等于 12)\n      \"w\": 12,\n      // 高度(占几行,取值范围 1~N,同一行的图表必须保持相同高度)\n      \"h\": 6,\n      // 最小宽度(可选,限制用户拖拽缩放的下限)\n      \"minW\": 4,\n      // 最小高度(可选,限制用户拖拽缩放的下限)\n      \"minH\": 3,\n      // 可选,所属分组 ID(用于 COMPOSITE_LAYOUT 组合布局,不使用分组时省略)\n      \"parentId\": \"group_001\"\n    }\n  ]\n}",
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Dashboard config example returned. The 'id' field is for structure reference only; it will be auto-generated by the server when creating a dashboard. The 'layout' field is ignored when creating (defaults to empty array), but effective when updating."
-}
dws aitable +dashboard-delete --base-id <base-id> --dashboard-id <dashboard-id> --reason <reason>high-risk-write
删除指定 dashboard(级联删除其 chart,不可逆)
dws mcp aitable delete_dashboard --json '{"baseId":<..>, "dashboardId":<..>, "reason":<..>}'lark-cli base +dashboard-delete已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/delete_dashboard
输入 / CLI 输出
输入
dws aitable +dashboard-delete --base-id x --dashboard-id x --reason x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x",
-    "reason": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_dashboard"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x",
-    "reason": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_dashboard"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1247ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +dashboard-delete --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete dashboard DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998577042546e0941\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete dashboard DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998577042546e0941\"}"
-  }
-}
dws aitable +dashboard-get --base-id <base-id> --dashboard-id <dashboard-id>read
获取指定 dashboard 的详细信息(含 charts summary)
dws mcp aitable get_dashboard --json '{"baseId":<..>, "dashboardId":<..>}'lark-cli base +dashboard-get已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_dashboard
输入 / CLI 输出
输入
dws aitable +dashboard-get --base-id x --dashboard-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_dashboard"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_dashboard"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1179ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +dashboard-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --yes --format json
真实 stdout
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "chartCount": 8,
-    "charts": [
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-dlxFo0tNSImp4ITpDn5fQ",
-        "chartName": "各业务线未部署数",
-        "chartType": "BAR",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-OQW55ZoOPT3abjYxQbdBo",
-        "chartName": "部署完成率",
-        "chartType": "PIE",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "0ed6ae150c7e43508d504b741ca1b19a",
-        "chartName": "设备付费情况分布",
-        "chartType": "PIE",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-dqjTHaYZdhbSTwRk3JEjI",
-        "chartName": "领取人未部署排行",
-        "chartType": "LEADERBOARD",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-qqMeLF69BEVnKCglPUPRw",
-        "chartName": "验证情况分析",
-        "chartType": "BAR",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-tIkpYHZqciaEu0ypokSEt",
-        "chartName": "部署时间趋势",
-        "chartType": "LINE",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-PcnRUOqW5RzJHFZ1BxQJe",
-        "chartName": "客户未部署分布",
-        "chartType": "HISTOGRAM",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-8LICblz3EqyoLbkyM22Bw",
-        "chartName": "付费状态漏斗",
-        "chartType": "FUNNEL",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      }
-    ],
-    "dashboardId": "KY9tlWg5NEHgfs8WT6IO2",
-    "dashboardName": "仪表盘",
-    "filters": [],
-    "layout": [
-      {
-        "h": 4,
-        "i": "widget-05w7XiwviqQgWrMxmvP20",
-        "w": 12,
-        "x": 0,
-        "y": 0
-      },
-      {
-        "h": 5,
-        "i": "widget-dlxFo0tNSImp4ITpDn5fQ",
-        "w": 6,
-        "x": 0,
-        "y": 4
-      },
-      {
-        "h": 5,
-        "i": "widget-PcnRUOqW5RzJHFZ1BxQJe",
-        "w": 6,
-        "x": 6,
-        "y": 4
-      },
-      {
-        "h": 5,
-        "i": "widget-qqMeLF69BEVnKCglPUPRw",
-        "w": 6,
-        "x": 0,
-        "y": 9
-      },
-      {
-        "h": 5,
-        "i": "widget-tIkpYHZqciaEu0ypokSEt",
-        "w": 6,
-        "x": 6,
-        "y": 9
-      },
-      {
-        "h": 5,
-        "i": "widget-OQW55ZoOPT3abjYxQbdBo",
-        "w": 4,
-        "x": 0,
-        "y": 14
-      },
-      {
-        "h": 5,
-        "i": "widget-dqjTHaYZdhbSTwRk3JEjI",
-        "w": 4,
-        "x": 4,
-        "y": 14
-      },
-      {
-        "h": 5,
-        "i": "widget-8LICblz3EqyoLbkyM22Bw",
-        "w": 4,
-        "x": 8,
-        "y": 14
-      },
-      {
-        "h": 4,
-        "i": "0ed6ae150c7e43508d504b741ca1b19a",
-        "w": 6,
-        "x": 0,
-        "y": 0
-      }
-    ],
-    "meta": {}
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got dashboard KY9tlWg5NEHgfs8WT6IO2. URL rules: AI 表格仪表盘访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{dashboardId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "chartCount": 8,
-    "charts": [
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-dlxFo0tNSImp4ITpDn5fQ",
-        "chartName": "各业务线未部署数",
-        "chartType": "BAR",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-OQW55ZoOPT3abjYxQbdBo",
-        "chartName": "部署完成率",
-        "chartType": "PIE",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "0ed6ae150c7e43508d504b741ca1b19a",
-        "chartName": "设备付费情况分布",
-        "chartType": "PIE",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-dqjTHaYZdhbSTwRk3JEjI",
-        "chartName": "领取人未部署排行",
-        "chartType": "LEADERBOARD",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-qqMeLF69BEVnKCglPUPRw",
-        "chartName": "验证情况分析",
-        "chartType": "BAR",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-tIkpYHZqciaEu0ypokSEt",
-        "chartName": "部署时间趋势",
-        "chartType": "LINE",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-PcnRUOqW5RzJHFZ1BxQJe",
-        "chartName": "客户未部署分布",
-        "chartType": "HISTOGRAM",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      },
-      {
-        "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-        "chartId": "widget-8LICblz3EqyoLbkyM22Bw",
-        "chartName": "付费状态漏斗",
-        "chartType": "FUNNEL",
-        "dashboardId": "KY9tlWg5NEHgfs8WT6IO2"
-      }
-    ],
-    "dashboardId": "KY9tlWg5NEHgfs8WT6IO2",
-    "dashboardName": "仪表盘",
-    "filters": [],
-    "layout": [
-      {
-        "h": 4,
-        "i": "widget-05w7XiwviqQgWrMxmvP20",
-        "w": 12,
-        "x": 0,
-        "y": 0
-      },
-      {
-        "h": 5,
-        "i": "widget-dlxFo0tNSImp4ITpDn5fQ",
-        "w": 6,
-        "x": 0,
-        "y": 4
-      },
-      {
-        "h": 5,
-        "i": "widget-PcnRUOqW5RzJHFZ1BxQJe",
-        "w": 6,
-        "x": 6,
-        "y": 4
-      },
-      {
-        "h": 5,
-        "i": "widget-qqMeLF69BEVnKCglPUPRw",
-        "w": 6,
-        "x": 0,
-        "y": 9
-      },
-      {
-        "h": 5,
-        "i": "widget-tIkpYHZqciaEu0ypokSEt",
-        "w": 6,
-        "x": 6,
-        "y": 9
-      },
-      {
-        "h": 5,
-        "i": "widget-OQW55ZoOPT3abjYxQbdBo",
-        "w": 4,
-        "x": 0,
-        "y": 14
-      },
-      {
-        "h": 5,
-        "i": "widget-dqjTHaYZdhbSTwRk3JEjI",
-        "w": 4,
-        "x": 4,
-        "y": 14
-      },
-      {
-        "h": 5,
-        "i": "widget-8LICblz3EqyoLbkyM22Bw",
-        "w": 4,
-        "x": 8,
-        "y": 14
-      },
-      {
-        "h": 4,
-        "i": "0ed6ae150c7e43508d504b741ca1b19a",
-        "w": 6,
-        "x": 0,
-        "y": 0
-      }
-    ],
-    "meta": {}
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got dashboard KY9tlWg5NEHgfs8WT6IO2. URL rules: AI 表格仪表盘访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{dashboardId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。"
-}
dws aitable +dashboard-share-get --base-id <base-id> --dashboard-id <dashboard-id>read
查询 dashboard 的分享配置
dws mcp aitable get_dashboard_share --json '{"baseId":<..>, "dashboardId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_dashboard_share
输入 / CLI 输出
输入
dws aitable +dashboard-share-get --base-id x --dashboard-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_dashboard_share"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "dashboardId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_dashboard_share"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1472ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test aitable +dashboard-share-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"Not Found\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get dashboard share config for dashboard KY9tlWg5NEHgfs8WT6IO2\",\"trace_id\":\"0bb7c36217840997497315836e07fe\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"Not Found\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get dashboard share config for dashboard KY9tlWg5NEHgfs8WT6IO2\",\"trace_id\":\"0bb7c36217840997497315836e07fe\"}"
-  }
-}
dws aitable +dashboard-share-update --base-id <base-id> --dashboard-id <dashboard-id> --enabled <enabled> --share-type <share-type> --allow-back-to-doc <allow-back-to-doc>write
开启/关闭 dashboard 分享并可设置分享类型
dws mcp aitable update_dashboard_share --json '{"baseId":<..>, "dashboardId":<..>, "enabled":<..>, "shareType":<..>, "allowBackToDoc":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/update_dashboard_share
输入 / CLI 输出
输入
dws aitable +dashboard-share-update --base-id x --dashboard-id x --enabled --share-type PUBLIC --allow-back-to-doc --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "allowBackToDoc": true,
-    "baseId": "x",
-    "dashboardId": "x",
-    "enabled": true,
-    "shareType": "PUBLIC"
-  },
-  "product": "aitable",
-  "tool": "update_dashboard_share"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "allowBackToDoc": true,
-    "baseId": "x",
-    "dashboardId": "x",
-    "enabled": true,
-    "shareType": "PUBLIC"
-  },
-  "product": "aitable",
-  "tool": "update_dashboard_share"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1115ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +dashboard-share-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --enabled --share-type PUBLIC --allow-back-to-doc --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update dashboard share for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998588081246e0853\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update dashboard share for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998588081246e0853\"}"
-  }
-}
dws aitable +dashboard-update --base-id <base-id> --dashboard-id <dashboard-id> --config <config> --name <name>write
更新指定 dashboard 的配置
dws mcp aitable update_dashboard --json '{"baseId":<..>, "dashboardId":<..>, "config":<..>}'lark-cli base +dashboard-update实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--config JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +dashboard-update --base-id x --dashboard-id x --config x --name x --yes
stdout
(空)
stderr / error
--config JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--config JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1157ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +dashboard-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --name 'DWS shortcut 真实测试 20260715-151724' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update dashboard DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998599403187e08b8\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update dashboard DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998599403187e08b8\"}"
-  }
-}
dws aitable +export-data --base-id <base-id> --task-id <task-id> --scope <scope> --format <format> --table-id <table-id> --view-id <view-id> --timeout-ms <timeout-ms>read
导出 AI 表格数据(创建导出任务或按 taskId 续等)
dws mcp aitable export_data --json '{"baseId":<..>, "taskId":<..>, "scope":<..>, "format":<..>, "tableId":<..>, "viewId":<..>, "timeoutMs":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/export_data
输入 / CLI 输出
输入
dws aitable +export-data --base-id x --task-id x --scope all --format excel --table-id x --view-id x --timeout-ms 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "taskId": "x",
-    "timeoutMs": 1,
-    "viewId": "x"
-  },
-  "product": "aitable",
-  "tool": "export_data"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "taskId": "x",
-    "timeoutMs": 1,
-    "viewId": "x"
-  },
-  "product": "aitable",
-  "tool": "export_data"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1754ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +export-data --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --task-id DWSREALREADNOSUCHID0000000000000 --scope all --format excel --table-id hERWDMS --view-id qvGDAH2 --timeout-ms 1 --yes
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"taskId cannot be combined with scope, format, tableId or viewId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to continue export because taskId mode cannot include create-task params\",\"trace_id\":\"2104a64c17840997514714448e0817\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"taskId cannot be combined with scope, format, tableId or viewId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to continue export because taskId mode cannot include create-task params\",\"trace_id\":\"2104a64c17840997514714448e0817\"}"
-  }
-}
dws aitable +field-delete --base-id <base-id> --table-id <table-id> --field-id <field-id>high-risk-write
删除指定字段(不可逆)
dws mcp aitable delete_field --json '{"baseId":<..>, "tableId":<..>, "fieldId":<..>}'lark-cli base +field-delete已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/delete_field
输入 / CLI 输出
输入
dws aitable +field-delete --base-id x --table-id x --field-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_field"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_field"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1157ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +field-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get current field info before delete\",\"trace_id\":\"0bab027317840998611338768e08c8\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get current field info before delete\",\"trace_id\":\"0bab027317840998611338768e08c8\"}"
-  }
-}
dws aitable +field-get --base-id <base-id> --table-id <table-id> --field-ids <field-ids>read
批量获取字段详情(含类型相关完整配置)
dws mcp aitable get_fields --json '{"baseId":<..>, "tableId":<..>, "fieldIds":<..>}'lark-cli base +field-get已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_fields
输入 / CLI 输出
输入
dws aitable +field-get --base-id x --table-id x --field-ids x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldIds": [
-      "x"
-    ],
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_fields"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldIds": [
-      "x"
-    ],
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_fields"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1402ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +field-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --field-ids 01ZM8y7 --yes --format json
真实 stdout
{
-  "data": {
-    "fields": [
-      {
-        "aiConfig": {
-          "autoRecompute": false,
-          "computeOnEmptyRef": false,
-          "enableThinking": false,
-          "enableWebSearch": false,
-          "imageConfig": {
-            "aiGeneratedWatermark": true,
-            "count": 1,
-            "resolution": "1024*1024"
-          },
-          "outputType": "text",
-          "prompt": [
-            {
-              "type": "text",
-              "value": "你是一位贴心的文字识别助手,当用户需要识别图片中的文字时(如菜单、传单、说明书),清晰识别出内容。\n\n执行前严格验证输入:\n- 如果未提供图片,立即回复:\"请上传需要识别文字的图片,我帮您快速查看内容!\"\n- 如果图片模糊/黑暗,回复:\"图片不太清楚,建议:1) 重新拍摄 2) 对准文字区域 3) 确保光线充足\"\n当输入有效时,按以下规则生成结果(禁用任何技术格式):\n1. 识别原则(像真人一样读图)\n   - 严格按阅读顺序朗读文字,保留原换行与空格\n   - 模糊处标注\"(看不清)\",不猜测内容(例:\"价(格)\")\n   - 保留所有标点、符号和大小写(如\"¥25\"不改为\"25元\")\n   - 表格按\"从左到右、从上到下\"顺序朗读\n2. 输出格式要求(必须遵守)\n   - 禁用JSON/XML/代码块,仅用**模拟纸质文档**的直观格式\n   - 示例(基于用户输入):\n     欢迎光临\n     ABC咖啡店\n     今日特惠:\n     拿铁 ¥25\n     卡布奇诺 ¥28\n     营业时间:8:00-22:00\n3. 关键控制规则\n   - 排版还原:用空行分隔段落,保留项目符号(如\"•\")\n   - 模糊处理:单个模糊字符→\"(看不清)\",连续模糊→\"(此处有文字但看不清)\"\n   - 语言提示:仅列出主要语言(如\"中文为主,含少量英文\")\n   - 空结果处理:写\"未检测到清晰文字,请检查图片\"\n   - 绝对禁止:不添加任何解释(如\"根据OCR技术...\")\n4. 禁止行为\n   × 生成任何JSON/XML代码  \n   × 使用术语如\"full_text\"、\"lines\"  \n   × 修改原始文字内容(如\"¥25\"不改为\"25元\")\n\n下面是你要总结和分析的原始内容"
-            },
-            {
-              "fieldId": "2hfk5Hd",
-              "type": "fieldRef"
-            },
-            {
-              "type": "text",
-              "value": ",只保留识别结果中的“序列号”或者“Serial No\""
-            }
-          ],
-          "type": "ai-agent"
-        },
-        "fieldId": "01ZM8y7",
-        "fieldName": "序列号",
-        "type": "text"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got 1 fields from table hERWDMS"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "fields": [
-      {
-        "aiConfig": {
-          "autoRecompute": false,
-          "computeOnEmptyRef": false,
-          "enableThinking": false,
-          "enableWebSearch": false,
-          "imageConfig": {
-            "aiGeneratedWatermark": true,
-            "count": 1,
-            "resolution": "1024*1024"
-          },
-          "outputType": "text",
-          "prompt": [
-            {
-              "type": "text",
-              "value": "你是一位贴心的文字识别助手,当用户需要识别图片中的文字时(如菜单、传单、说明书),清晰识别出内容。\n\n执行前严格验证输入:\n- 如果未提供图片,立即回复:\"请上传需要识别文字的图片,我帮您快速查看内容!\"\n- 如果图片模糊/黑暗,回复:\"图片不太清楚,建议:1) 重新拍摄 2) 对准文字区域 3) 确保光线充足\"\n当输入有效时,按以下规则生成结果(禁用任何技术格式):\n1. 识别原则(像真人一样读图)\n   - 严格按阅读顺序朗读文字,保留原换行与空格\n   - 模糊处标注\"(看不清)\",不猜测内容(例:\"价(格)\")\n   - 保留所有标点、符号和大小写(如\"¥25\"不改为\"25元\")\n   - 表格按\"从左到右、从上到下\"顺序朗读\n2. 输出格式要求(必须遵守)\n   - 禁用JSON/XML/代码块,仅用**模拟纸质文档**的直观格式\n   - 示例(基于用户输入):\n     欢迎光临\n     ABC咖啡店\n     今日特惠:\n     拿铁 ¥25\n     卡布奇诺 ¥28\n     营业时间:8:00-22:00\n3. 关键控制规则\n   - 排版还原:用空行分隔段落,保留项目符号(如\"•\")\n   - 模糊处理:单个模糊字符→\"(看不清)\",连续模糊→\"(此处有文字但看不清)\"\n   - 语言提示:仅列出主要语言(如\"中文为主,含少量英文\")\n   - 空结果处理:写\"未检测到清晰文字,请检查图片\"\n   - 绝对禁止:不添加任何解释(如\"根据OCR技术...\")\n4. 禁止行为\n   × 生成任何JSON/XML代码  \n   × 使用术语如\"full_text\"、\"lines\"  \n   × 修改原始文字内容(如\"¥25\"不改为\"25元\")\n\n下面是你要总结和分析的原始内容"
-            },
-            {
-              "fieldId": "2hfk5Hd",
-              "type": "fieldRef"
-            },
-            {
-              "type": "text",
-              "value": ",只保留识别结果中的“序列号”或者“Serial No\""
-            }
-          ],
-          "type": "ai-agent"
-        },
-        "fieldId": "01ZM8y7",
-        "fieldName": "序列号",
-        "type": "text"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got 1 fields from table hERWDMS"
-}
dws aitable +field-update --base-id <base-id> --table-id <table-id> --field-id <field-id> --name <name> --config <config> --ai-config <ai-config>write
更新字段名称 / 配置 / AI 配置(类型不可改)
dws mcp aitable update_field --json '{"baseId":<..>, "tableId":<..>, "fieldId":<..>, "newFieldName":<..>, "config":<..>, "aiConfig":<..>}'lark-cli base +field-update实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--config JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +field-update --base-id x --table-id x --field-id x --name x --config x --ai-config x --yes
stdout
(空)
stderr / error
--config JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--config JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1196ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --config '{}' --ai-config '{}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get current field info for update\",\"trace_id\":\"213ee25c17840998623207342e08e2\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get current field info for update\",\"trace_id\":\"213ee25c17840998623207342e08e2\"}"
-  }
-}
dws aitable +find-record --base <base> --table <table> --query <query>read复合/智能型
在指定多维表里按关键词查记录(只读)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 query_recordslark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/query_records
输入 / CLI 输出
输入
dws aitable +find-record --base x --table x --query x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "keyword": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "query_records"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "keyword": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "query_records"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2849ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +find-record --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table hERWDMS --query '测试' --yes --format json
真实 stdout
{
-  "data": {
-    "nextCursor": "oXh7WgGooe",
-    "records": [
-      {
-        "cells": {
-          "01ZM8y7": "N0QWY69QN4",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4048.HEIC.JPG",
-              "resourceId": "479e4321-3d6a-42f0-91de-583c6ea41d9f",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5268e3736ebad509e7fcef9dcd108f495a911ef939606b63dc103944eb9062bae7f23148f19bc87f4",
-              "size": 1753393,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "8LVOssPz6Q"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "CDQD7CN97V",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4054.HEIC.JPG",
-              "resourceId": "7d68adf9-ec88-4192-8728-5de240f17fc8",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d57cd5cd33059ea9b3d2766775dd1ba8096b8c5a9cb3defd6f5321c276624c51f602fba57aca34bc6d",
-              "size": 1801325,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试\nAI营销借用",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "133206"
-            }
-          ]
-        },
-        "recordId": "AjOk0hu1pe"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "RY7LCW719Q",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4213.HEIC.JPG",
-              "resourceId": "0022f2cc-bd93-49f0-8039-04ac2909dd32",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d59a2750fefa807e0cfc41158e98407c864f1745537f406fcb0e02c76b1696b84f8bac5e1298eba711",
-              "size": 1763574,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "DUDacJdQ2X"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "D54X6C7WH6",
-          "2hfk5Hd": [
-            {
-              "filename": "image.png",
-              "resourceId": "e7dea77f-0f65-4266-a3fd-e401b80a9582",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5d45d39275c65656906af57b60be7002feaf431efea862523554a56545a2e6ac55d17ee7f4c9382c9",
-              "size": 9795238,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ],
-          "ty1M3J4": {
-            "markdown": "已归还\n"
-          }
-        },
-        "recordId": "Dt8KZX7Nt2"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "MN7V04VVD4",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4044.HEIC.JPG",
-              "resourceId": "82903e20-99ca-4497-8596-64b8a9511347",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5585fe6169387aa3767c7e8d94f1e6b2bdcb910b90a359120b13c8f9d1a59101157155a75536e1183",
-              "size": 1965625,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "FA0TZMthqI"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "CYY9PQRXJ5",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4216.HEIC.JPG",
-              "resourceId": "3dfd0bec-a06a-4580-a9a2-b8ae450df2a4",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d52f04469c6bb09596caffbbac393c775d8db27175749aef18fb6444ef163ea9d17fb0a232eda6495f",
-              "size": 2290335,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "IyGa4g01n1"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "K30F574N0W",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4071.HEIC.JPG",
-              "resourceId": "8f970cbb-9e4d-4abb-bc2e-bae127a6231a",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5988e57da667e94ed89271196611e117503f232ffda40509efa8b2836f86122df894b0282d28e9bd0",
-              "size": 1883600,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "UJwTYA3uNw"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "Y7CXVJ9XWD",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4072.HEIC.JPG",
-              "resourceId": "68b711bd-e972-445a-be79-bf4954b52000",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d581be5bba89215dfa8bf27c7a750a77959fc36654d351427344ca8cee273282f51f530a2b9381f2f5",
-              "size": 1856927,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "Uygc2paXj3"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "GMQW2JLWVY",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4214.HEIC.JPG",
-              "resourceId": "ca4deb91-17d6-43e7-9812-72101643deb9",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d57860f934d7f4ae4fd1379962b8f251e8357d71880d1389e2b613f13514ac39fcfcb2a6a1fcb68a65",
-              "size": 1640058,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "ewj09NwU1z",
-              "name": "AI招聘"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "429676"
-            }
-          ]
-        },
-        "recordId": "Xjj4e7GgG8"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "Y0V7Y2N2YD",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4074.HEIC.JPG",
-              "resourceId": "5b8d3006-280e-41af-a4b0-3320e6a5d596",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5f3f03272cd3d6f49910031a2e873f763b035f6f21d03abba7ab038475a5ab532635226d96df6a64e",
-              "size": 1964512,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "Y056lsOG61"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "P24GFQN7YT",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4033.HEIC.JPG",
-              "resourceId": "38656067-b7b7-45fd-8bea-64f4c89dc4f7",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d50c8666278c495a44ef70265cd8adb7f504b841c8f61607d92f11852026cd92eff88db6bf5a5d630c",
-              "size": 2028953,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "YVaPzcuGBa"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "N7Q33L77WR",
-          "2hfk5Hd": [
-            {
-              "filename": "image.png",
-              "resourceId": "4abf67c0-ad03-4a81-a3db-3794d15453e9",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d53aa03710ddc5973e4df2b4d6c72f04177dd7b5a30206f588777548a9d514bbd2a1bdc9489688a5fb",
-              "size": 9870487,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "hXTMF9el7v"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "HFk9Y9TYQD",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4043.HEIC.JPG",
-              "resourceId": "d6786a91-bca7-4c09-8225-9503cb66610f",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5fe8628219f060fbe50542a5616689036033b038792e8d5a77e12f7fc37630879073a357b1dacf1ae",
-              "size": 2110186,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "kk7eQt1kJB"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "TX6377KGFH",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4064.HEIC.JPG",
-              "resourceId": "850e1a7f-aa0a-4ba8-9b54-5bc9c66a5a63",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5716c6100d05323cbd2151a4b9cf8174b09cf0ac1af356178c4572bfa55a8c065f499293f0f9b00e2",
-              "size": 1963615,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试--恒飞",
-          "ARoUEWW": {
-            "id": "Zw0fFCNl8e",
-            "name": "付费"
-          },
-          "ZoCsF67": "2026-01-23T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "ewj09NwU1z",
-              "name": "AI招聘"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "103262"
-            }
-          ],
-          "ty1M3J4": {
-            "markdown": "目前看设备离线\n"
-          }
-        },
-        "recordId": "oXh7WgGooe"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Successfully queried 14 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "nextCursor": "oXh7WgGooe",
-    "records": [
-      {
-        "cells": {
-          "01ZM8y7": "N0QWY69QN4",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4048.HEIC.JPG",
-              "resourceId": "479e4321-3d6a-42f0-91de-583c6ea41d9f",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5268e3736ebad509e7fcef9dcd108f495a911ef939606b63dc103944eb9062bae7f23148f19bc87f4",
-              "size": 1753393,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "8LVOssPz6Q"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "CDQD7CN97V",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4054.HEIC.JPG",
-              "resourceId": "7d68adf9-ec88-4192-8728-5de240f17fc8",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d57cd5cd33059ea9b3d2766775dd1ba8096b8c5a9cb3defd6f5321c276624c51f602fba57aca34bc6d",
-              "size": 1801325,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试\nAI营销借用",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "133206"
-            }
-          ]
-        },
-        "recordId": "AjOk0hu1pe"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "RY7LCW719Q",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4213.HEIC.JPG",
-              "resourceId": "0022f2cc-bd93-49f0-8039-04ac2909dd32",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d59a2750fefa807e0cfc41158e98407c864f1745537f406fcb0e02c76b1696b84f8bac5e1298eba711",
-              "size": 1763574,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "DUDacJdQ2X"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "D54X6C7WH6",
-          "2hfk5Hd": [
-            {
-              "filename": "image.png",
-              "resourceId": "e7dea77f-0f65-4266-a3fd-e401b80a9582",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5d45d39275c65656906af57b60be7002feaf431efea862523554a56545a2e6ac55d17ee7f4c9382c9",
-              "size": 9795238,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ],
-          "ty1M3J4": {
-            "markdown": "已归还\n"
-          }
-        },
-        "recordId": "Dt8KZX7Nt2"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "MN7V04VVD4",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4044.HEIC.JPG",
-              "resourceId": "82903e20-99ca-4497-8596-64b8a9511347",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5585fe6169387aa3767c7e8d94f1e6b2bdcb910b90a359120b13c8f9d1a59101157155a75536e1183",
-              "size": 1965625,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "FA0TZMthqI"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "CYY9PQRXJ5",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4216.HEIC.JPG",
-              "resourceId": "3dfd0bec-a06a-4580-a9a2-b8ae450df2a4",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d52f04469c6bb09596caffbbac393c775d8db27175749aef18fb6444ef163ea9d17fb0a232eda6495f",
-              "size": 2290335,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "IyGa4g01n1"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "K30F574N0W",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4071.HEIC.JPG",
-              "resourceId": "8f970cbb-9e4d-4abb-bc2e-bae127a6231a",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5988e57da667e94ed89271196611e117503f232ffda40509efa8b2836f86122df894b0282d28e9bd0",
-              "size": 1883600,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "UJwTYA3uNw"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "Y7CXVJ9XWD",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4072.HEIC.JPG",
-              "resourceId": "68b711bd-e972-445a-be79-bf4954b52000",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d581be5bba89215dfa8bf27c7a750a77959fc36654d351427344ca8cee273282f51f530a2b9381f2f5",
-              "size": 1856927,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "Uygc2paXj3"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "GMQW2JLWVY",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4214.HEIC.JPG",
-              "resourceId": "ca4deb91-17d6-43e7-9812-72101643deb9",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d57860f934d7f4ae4fd1379962b8f251e8357d71880d1389e2b613f13514ac39fcfcb2a6a1fcb68a65",
-              "size": 1640058,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "ewj09NwU1z",
-              "name": "AI招聘"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "429676"
-            }
-          ]
-        },
-        "recordId": "Xjj4e7GgG8"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "Y0V7Y2N2YD",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4074.HEIC.JPG",
-              "resourceId": "5b8d3006-280e-41af-a4b0-3320e6a5d596",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5f3f03272cd3d6f49910031a2e873f763b035f6f21d03abba7ab038475a5ab532635226d96df6a64e",
-              "size": 1964512,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "Y056lsOG61"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "P24GFQN7YT",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4033.HEIC.JPG",
-              "resourceId": "38656067-b7b7-45fd-8bea-64f4c89dc4f7",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d50c8666278c495a44ef70265cd8adb7f504b841c8f61607d92f11852026cd92eff88db6bf5a5d630c",
-              "size": 2028953,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "YVaPzcuGBa"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "N7Q33L77WR",
-          "2hfk5Hd": [
-            {
-              "filename": "image.png",
-              "resourceId": "4abf67c0-ad03-4a81-a3db-3794d15453e9",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d53aa03710ddc5973e4df2b4d6c72f04177dd7b5a30206f588777548a9d514bbd2a1bdc9489688a5fb",
-              "size": 9870487,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "hXTMF9el7v"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "HFk9Y9TYQD",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4043.HEIC.JPG",
-              "resourceId": "d6786a91-bca7-4c09-8225-9503cb66610f",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5fe8628219f060fbe50542a5616689036033b038792e8d5a77e12f7fc37630879073a357b1dacf1ae",
-              "size": 2110186,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试",
-          "6B78kgr": "2",
-          "ARoUEWW": {
-            "id": "P6GYJKc3t0",
-            "name": "未付费"
-          },
-          "Str3hFF": {
-            "id": "m4Y2qZ07Yk",
-            "name": "领取两台"
-          },
-          "ZoCsF67": "2026-02-09T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "lpH5zwRv5D",
-              "name": "AI差旅"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "151705"
-            }
-          ]
-        },
-        "recordId": "kk7eQt1kJB"
-      },
-      {
-        "cells": {
-          "01ZM8y7": "TX6377KGFH",
-          "2hfk5Hd": [
-            {
-              "filename": "IMG_4064.HEIC.JPG",
-              "resourceId": "850e1a7f-aa0a-4ba8-9b54-5bc9c66a5a63",
-              "resourceUrl": "/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5716c6100d05323cbd2151a4b9cf8174b09cf0ac1af356178c4572bfa55a8c065f499293f0f9b00e2",
-              "size": 1963615,
-              "type": "image",
-              "url": "[REDACTED_OSS_URL]"
-            }
-          ],
-          "47bTcjO": "内部测试--恒飞",
-          "ARoUEWW": {
-            "id": "Zw0fFCNl8e",
-            "name": "付费"
-          },
-          "ZoCsF67": "2026-01-23T00:00:00+08:00",
-          "j1Zwe1c": [
-            {
-              "id": "s9UKjSzdLg",
-              "name": "业务方自主"
-            }
-          ],
-          "kFwb8br": {
-            "id": "1ZiuS5Wlgz",
-            "name": "内部部署"
-          },
-          "rjwzzIu": [
-            {
-              "id": "ewj09NwU1z",
-              "name": "AI招聘"
-            }
-          ],
-          "t9n9SDE": [
-            {
-              "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-              "userId": "103262"
-            }
-          ],
-          "ty1M3J4": {
-            "markdown": "目前看设备离线\n"
-          }
-        },
-        "recordId": "oXh7WgGooe"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Successfully queried 14 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
dws aitable +form-delete --base-id <base-id> --table-id <table-id> --view-id <view-id>high-risk-write
删除指定表单视图(不可逆)
dws mcp aitable delete_form_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>}'lark-cli base +form-delete已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/delete_form_view
输入 / CLI 输出
输入
dws aitable +form-delete --base-id x --table-id x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "delete_form_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "delete_form_view"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1290ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +form-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete form view for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998636217552e08e2\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete form view for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998636217552e08e2\"}"
-  }
-}
dws aitable +form-field-hide --base-id <base-id> --table-id <table-id> --view-id <view-id> --field-id <field-id> --hidden <hidden>write
切换表单字段的隐藏/显示状态
dws mcp aitable update_form_field_hidden --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "fieldId":<..>, "hidden":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/update_form_field_hidden
输入 / CLI 输出
输入
dws aitable +form-field-hide --base-id x --table-id x --view-id x --field-id x --hidden --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldId": "x",
-    "hidden": true,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_form_field_hidden"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldId": "x",
-    "hidden": true,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_form_field_hidden"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1312ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +form-field-hide --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --hidden --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update form field hidden for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998649456212e08df\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update form field hidden for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998649456212e08df\"}"
-  }
-}
dws aitable +form-field-list --base-id <base-id> --table-id <table-id> --view-id <view-id>read
列出表单视图当前可见的字段及其配置
dws mcp aitable list_form_fields --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/list_form_fields
输入 / CLI 输出
输入
dws aitable +form-field-list --base-id x --table-id x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_form_fields"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_form_fields"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1139ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +form-field-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id lmeV1cb --yes --format json
真实 stdout
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "fields": [
-      {
-        "fieldId": "t9n9SDE",
-        "hidden": false,
-        "name": "领取人",
-        "required": true,
-        "type": "user"
-      },
-      {
-        "fieldId": "j1Zwe1c",
-        "hidden": false,
-        "name": "验证情况",
-        "required": true,
-        "type": "multipleSelect"
-      },
-      {
-        "fieldId": "2hfk5Hd",
-        "hidden": false,
-        "name": "商品图片",
-        "required": false,
-        "type": "file"
-      },
-      {
-        "fieldId": "ZoCsF67",
-        "hidden": false,
-        "name": "领取时间",
-        "required": true,
-        "type": "date"
-      },
-      {
-        "fieldId": "47bTcjO",
-        "hidden": false,
-        "name": "交付客户",
-        "required": true,
-        "type": "text"
-      },
-      {
-        "description": "填写:客户业务介绍,老板是谁,实际使用部门,使用人数,主要解决客户什么问题,客户原声",
-        "fieldId": "3p4hbFh",
-        "hidden": false,
-        "name": "客户背景",
-        "required": true,
-        "type": "richText"
-      }
-    ],
-    "tableId": "hERWDMS",
-    "viewId": "lmeV1cb"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Found 6 field(s) in form view lmeV1cb"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "fields": [
-      {
-        "fieldId": "t9n9SDE",
-        "hidden": false,
-        "name": "领取人",
-        "required": true,
-        "type": "user"
-      },
-      {
-        "fieldId": "j1Zwe1c",
-        "hidden": false,
-        "name": "验证情况",
-        "required": true,
-        "type": "multipleSelect"
-      },
-      {
-        "fieldId": "2hfk5Hd",
-        "hidden": false,
-        "name": "商品图片",
-        "required": false,
-        "type": "file"
-      },
-      {
-        "fieldId": "ZoCsF67",
-        "hidden": false,
-        "name": "领取时间",
-        "required": true,
-        "type": "date"
-      },
-      {
-        "fieldId": "47bTcjO",
-        "hidden": false,
-        "name": "交付客户",
-        "required": true,
-        "type": "text"
-      },
-      {
-        "description": "填写:客户业务介绍,老板是谁,实际使用部门,使用人数,主要解决客户什么问题,客户原声",
-        "fieldId": "3p4hbFh",
-        "hidden": false,
-        "name": "客户背景",
-        "required": true,
-        "type": "richText"
-      }
-    ],
-    "tableId": "hERWDMS",
-    "viewId": "lmeV1cb"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Found 6 field(s) in form view lmeV1cb"
-}
dws aitable +form-field-update --base-id <base-id> --table-id <table-id> --view-id <view-id> --field-id <field-id> --required <required> --field-description <field-description>write
更新表单字段的必填状态或描述
dws mcp aitable update_form_field --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "fieldId":<..>, "required":<..>, "fieldDescription":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/update_form_field
输入 / CLI 输出
输入
dws aitable +form-field-update --base-id x --table-id x --view-id x --field-id x --required --field-description x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldDescription": "x",
-    "fieldId": "x",
-    "required": true,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_form_field"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldDescription": "x",
-    "fieldId": "x",
-    "required": true,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_form_field"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1153ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +form-field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --required --field-description 'DWS shortcut 真实测试字段说明' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update form field config for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998660847932e08e2\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update form field config for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998660847932e08e2\"}"
-  }
-}
dws aitable +form-list --base-id <base-id> --table-id <table-id>read
列出指定数据表下的所有表单视图
dws mcp aitable <tool>lark-cli base +form-list已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/list_form_views
输入 / CLI 输出
输入
dws aitable +form-list --base-id x --table-id x --yes
stdout
{
-  "count": 0,
-  "forms": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "forms": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1003ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +form-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --yes --format json
真实 stdout
{
-  "count": 2,
-  "forms": [
-    {
-      "viewId": "lmeV1cb",
-      "viewName": "设备登记"
-    },
-    {
-      "viewId": "HaFV1LW",
-      "viewName": "客户使用情况"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 2,
-  "forms": [
-    {
-      "viewId": "lmeV1cb",
-      "viewName": "设备登记"
-    },
-    {
-      "viewId": "HaFV1LW",
-      "viewName": "客户使用情况"
-    }
-  ]
-}
dws aitable +form-share-get --base-id <base-id> --table-id <table-id> --view-id <view-id>read
读取视图当前的分享表单配置
dws mcp aitable get_share_form_config --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_share_form_config
输入 / CLI 输出
输入
dws aitable +form-share-get --base-id x --table-id x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_share_form_config"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_share_form_config"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1067ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +form-share-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id lmeV1cb --yes --format json
真实 stdout
{
-  "data": {
-    "anonymousSubmit": false,
-    "authTypeCode": 0,
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "enabled": true,
-    "formName": "Mac Mini领取信息登记",
-    "loadLastSubmit": true,
-    "punished": false,
-    "replyNotice": false,
-    "shareFormUuid": "v01ZWGl05mvrY0PMn34_hERWDMS_lmeV1cb",
-    "status": 1,
-    "submitTimesUserLimit": 0,
-    "tableId": "hERWDMS",
-    "viewId": "lmeV1cb"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Fetched share form config for view lmeV1cb: enabled=true"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "anonymousSubmit": false,
-    "authTypeCode": 0,
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "enabled": true,
-    "formName": "Mac Mini领取信息登记",
-    "loadLastSubmit": true,
-    "punished": false,
-    "replyNotice": false,
-    "shareFormUuid": "v01ZWGl05mvrY0PMn34_hERWDMS_lmeV1cb",
-    "status": 1,
-    "submitTimesUserLimit": 0,
-    "tableId": "hERWDMS",
-    "viewId": "lmeV1cb"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Fetched share form config for view lmeV1cb: enabled=true"
-}
dws aitable +form-share-update --base-id <base-id> --table-id <table-id> --view-id <view-id> --enabled <enabled>write
开启或关闭指定视图的分享表单
dws mcp aitable update_share_form --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "enabled":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/update_share_form
输入 / CLI 输出
输入
dws aitable +form-share-update --base-id x --table-id x --view-id x --enabled true --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "enabled": "true",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_share_form"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "enabled": "true",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_share_form"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1573ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +form-share-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --enabled true --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update share form for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998675755538e087d\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update share form for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998675755538e087d\"}"
-  }
-}
dws aitable +form-update --base-id <base-id> --table-id <table-id> --view-id <view-id> --title <title> --description <description>write
更新表单标题 / 描述
dws mcp aitable update_form_info --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "title":<..>, "description":<..>}'lark-cli base +form-update已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/update_form_info
输入 / CLI 输出
输入
dws aitable +form-update --base-id x --table-id x --view-id x --title x --description x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "description": "x",
-    "tableId": "x",
-    "title": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_form_info"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "description": "x",
-    "tableId": "x",
-    "title": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "update_form_info"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1141ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +form-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --title 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update form info for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998688302645e0853\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update form info for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998688302645e0853\"}"
-  }
-}
dws aitable +import-data --import-id <import-id> --table-id <table-id> --timeout <timeout> --header-row <header-row> --src-sheet-name <src-sheet-name> --field-mapping <field-mapping>write
将已上传文件导入 AI 表格(新建表或追加到已有表)
dws mcp aitable import_data --json '{"importId":<..>, "tableId":<..>, "timeout":<..>, "headerRow":<..>, "srcSheetName":<..>, "fieldMapping":<..>}'lark base 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--field-mapping JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +import-data --import-id x --table-id x --timeout 1 --header-row 1 --src-sheet-name x --field-mapping x --yes
stdout
(空)
stderr / error
--field-mapping JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--field-mapping JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1227ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test aitable +import-data --import-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --timeout 1 --header-row 1 --src-sheet-name x --field-mapping '{}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_IMPORT_ID\",\"message\":\"importId not found: either invalid or expired\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"importId「DWSREALTESTNOSUCHID0000000000000」无效或已过期(有效期 1 小时)。请勿重试相同 importId。需重新上传:① 调用 prepare_import_upload 获取新的 uploadUrl 和 importId;② 通过 HTTP PUT 将原始文件字节流上传至 uploadUrl;除非 uploadUrl 明确要求,否则不要额外附带 Content-Type 等自定义请求头;③ 调用 import_data 传入新的 importId。\",\"trace_id\":\"213ee25c17840998700326443e0944\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_IMPORT_ID\",\"message\":\"importId not found: either invalid or expired\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"importId「DWSREALTESTNOSUCHID0000000000000」无效或已过期(有效期 1 小时)。请勿重试相同 importId。需重新上传:① 调用 prepare_import_upload 获取新的 uploadUrl 和 importId;② 通过 HTTP PUT 将原始文件字节流上传至 uploadUrl;除非 uploadUrl 明确要求,否则不要额外附带 Content-Type 等自定义请求头;③ 调用 import_data 传入新的 importId。\",\"trace_id\":\"213ee25c17840998700326443e0944\"}"
-  }
-}
dws aitable +import-upload --base-id <base-id> --file-name <file-name> --file-size <file-size>write
为导入任务申请 OSS 直传地址(uploadUrl / importId)
dws mcp aitable prepare_import_upload --json '{"baseId":<..>, "fileName":<..>, "fileSize":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/prepare_import_upload
输入 / CLI 输出
输入
dws aitable +import-upload --base-id x --file-name x --file-size 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fileName": "x",
-    "fileSize": 1
-  },
-  "product": "aitable",
-  "tool": "prepare_import_upload"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fileName": "x",
-    "fileSize": 1
-  },
-  "product": "aitable",
-  "tool": "prepare_import_upload"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1302ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +import-upload --base-id DWSREALTESTNOSUCHID0000000000000 --file-name dws-shortcut-real-20260715-151724.txt --file-size 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"无法解析 baseId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"无效的 baseId\",\"trace_id\":\"0bab027317840998713075490e096e\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"无法解析 baseId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"无效的 baseId\",\"trace_id\":\"0bab027317840998713075490e096e\"}"
-  }
-}
dws aitable +list-tables --base <base>read复合/智能型
列出某个多维表(base)里的所有数据表(只读,投影 tableId/tableName)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_tables
输入 / CLI 输出
输入
dws aitable +list-tables --base x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_tables"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable",
-  "tool": "get_tables"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1452ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +list-tables --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json
真实 stdout
{
-  "tables": [
-    {
-      "tableId": "hERWDMS",
-      "tableName": "Mac Mini"
-    },
-    {
-      "tableId": "k8d898q",
-      "tableName": "手机"
-    },
-    {
-      "tableId": "zyLRwwa",
-      "tableName": "SIM卡"
-    },
-    {
-      "tableId": "L53jNZz",
-      "tableName": "机箱"
-    },
-    {
-      "tableId": "zTiHNDL",
-      "tableName": "磁吸片"
-    },
-    {
-      "tableId": "anMdihs",
-      "tableName": "手机线"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "tables": [
-    {
-      "tableId": "hERWDMS",
-      "tableName": "Mac Mini"
-    },
-    {
-      "tableId": "k8d898q",
-      "tableName": "手机"
-    },
-    {
-      "tableId": "zyLRwwa",
-      "tableName": "SIM卡"
-    },
-    {
-      "tableId": "L53jNZz",
-      "tableName": "机箱"
-    },
-    {
-      "tableId": "zTiHNDL",
-      "tableName": "磁吸片"
-    },
-    {
-      "tableId": "anMdihs",
-      "tableName": "手机线"
-    }
-  ]
-}
dws aitable +record-delete --base-id <base-id> --table-id <table-id> --record-ids <record-ids>high-risk-write
批量删除记录(不可逆),单次最多 100 条
dws mcp aitable delete_records --json '{"baseId":<..>, "tableId":<..>, "recordIds":<..>}'lark-cli base +record-delete已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/delete_records
输入 / CLI 输出
输入
dws aitable +record-delete --base-id x --table-id x --record-ids x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordIds": [
-      "x"
-    ],
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_records"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordIds": [
-      "x"
-    ],
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_records"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1159ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +record-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --record-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete records\",\"trace_id\":\"2132f5ca17840998724753149e0853\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete records\",\"trace_id\":\"2132f5ca17840998724753149e0853\"}"
-  }
-}
dws aitable +record-history-list --base-id <base-id> --table-id <table-id> --record-id <record-id> --offset <offset> --limit <limit>read
按 recordId 查询单条记录的变更历史
dws mcp aitable query_record_history --json '{"baseId":<..>, "tableId":<..>, "recordId":<..>, "offset":<..>, "limit":<..>}'lark-cli base +record-history-list已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/query_record_history
输入 / CLI 输出
输入
dws aitable +record-history-list --base-id x --table-id x --record-id x --offset 1 --limit 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "limit": 1,
-    "offset": 1,
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "query_record_history"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "limit": 1,
-    "offset": 1,
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "query_record_history"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1156ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +record-history-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --offset 1 --limit 10 --yes --format json
真实 stdout
{
-  "data": {
-    "histories": [
-      {
-        "action": "updateRecords",
-        "newValue": "{\"47bTcjO\":{\"dataType\":\"STRING\",\"value\":\"山东梅格彤\"}}",
-        "oldValue": "{\"47bTcjO\":{\"dataType\":\"STRING\",\"value\":\"内部测试\"}}",
-        "operateTime": 1772700326253,
-        "type": "row",
-        "version": 2455
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ARoUEWW\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"Zw0fFCNl8e\",\"sequence\":\"Zw0fFCNl8e\",\"data\":\"Zw0fFCNl8e\",\"filterFactor\":\"Zw0fFCNl8e\"}}}",
-        "oldValue": "{\"ARoUEWW\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"P6GYJKc3t0\",\"identifier\":\"P6GYJKc3t0\",\"data\":\"P6GYJKc3t0\",\"filterFactor\":\"P6GYJKc3t0\"}}}",
-        "operateTime": 1772700311648,
-        "type": "row",
-        "version": 2454
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"uvZmbeb5RH\",\"sequence\":\"uvZmbeb5RH\",\"data\":\"uvZmbeb5RH\",\"filterFactor\":\"uvZmbeb5RH\"}}}",
-        "oldValue": "{}",
-        "operateTime": 1772700308342,
-        "type": "row",
-        "version": 2453
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{}",
-        "oldValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"1ZiuS5Wlgz\",\"identifier\":\"1ZiuS5Wlgz\",\"data\":\"1ZiuS5Wlgz\",\"filterFactor\":\"1ZiuS5Wlgz\"}}}",
-        "operateTime": 1772700305961,
-        "type": "row",
-        "version": 2452
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"给不挠了\",\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"filterFactor\":[\"给不挠了\"]}}}",
-        "operateTime": 1772700303480,
-        "type": "row",
-        "version": 2451
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"sequence\":\"给不挠了\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"filterFactor\":[\"给不挠了\"]}}}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"闲置\",\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"filterFactor\":[\"闲置\"]}}}",
-        "operateTime": 1772003207333,
-        "type": "row",
-        "version": 2377
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"sequence\":\"闲置\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"filterFactor\":[\"闲置\"]}}}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"sequence\":\"目前查是离线\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"filterFactor\":[\"目前查是离线\"]}}}",
-        "operateTime": 1770990377587,
-        "type": "row",
-        "version": 2241
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"1ZiuS5Wlgz\",\"sequence\":\"1ZiuS5Wlgz\",\"data\":\"1ZiuS5Wlgz\",\"filterFactor\":\"1ZiuS5Wlgz\"}}}",
-        "oldValue": "{}",
-        "operateTime": 1770875502420,
-        "type": "row",
-        "version": 2162
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{}",
-        "oldValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"8G9fnUmdKY\",\"sequence\":\"8G9fnUmdKY\",\"data\":\"8G9fnUmdKY\",\"filterFactor\":\"8G9fnUmdKY\"}}}",
-        "operateTime": 1770875501255,
-        "type": "row",
-        "version": 2161
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"sequence\":\"目前查是离线\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"filterFactor\":[\"目前查是离线\"]}}}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"\",\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"\\\"]]]]\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"\\\"]]]]\",\"filterFactor\":[\"\"]}}}",
-        "operateTime": 1770868083188,
-        "type": "row",
-        "version": 2139
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got 10 history record(s) for record 1015oH3OXy"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "histories": [
-      {
-        "action": "updateRecords",
-        "newValue": "{\"47bTcjO\":{\"dataType\":\"STRING\",\"value\":\"山东梅格彤\"}}",
-        "oldValue": "{\"47bTcjO\":{\"dataType\":\"STRING\",\"value\":\"内部测试\"}}",
-        "operateTime": 1772700326253,
-        "type": "row",
-        "version": 2455
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ARoUEWW\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"Zw0fFCNl8e\",\"sequence\":\"Zw0fFCNl8e\",\"data\":\"Zw0fFCNl8e\",\"filterFactor\":\"Zw0fFCNl8e\"}}}",
-        "oldValue": "{\"ARoUEWW\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"P6GYJKc3t0\",\"identifier\":\"P6GYJKc3t0\",\"data\":\"P6GYJKc3t0\",\"filterFactor\":\"P6GYJKc3t0\"}}}",
-        "operateTime": 1772700311648,
-        "type": "row",
-        "version": 2454
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"uvZmbeb5RH\",\"sequence\":\"uvZmbeb5RH\",\"data\":\"uvZmbeb5RH\",\"filterFactor\":\"uvZmbeb5RH\"}}}",
-        "oldValue": "{}",
-        "operateTime": 1772700308342,
-        "type": "row",
-        "version": 2453
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{}",
-        "oldValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"1ZiuS5Wlgz\",\"identifier\":\"1ZiuS5Wlgz\",\"data\":\"1ZiuS5Wlgz\",\"filterFactor\":\"1ZiuS5Wlgz\"}}}",
-        "operateTime": 1772700305961,
-        "type": "row",
-        "version": 2452
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"给不挠了\",\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"filterFactor\":[\"给不挠了\"]}}}",
-        "operateTime": 1772700303480,
-        "type": "row",
-        "version": 2451
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"sequence\":\"给不挠了\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"给不挠了\\\"]]]]\",\"filterFactor\":[\"给不挠了\"]}}}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"闲置\",\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"filterFactor\":[\"闲置\"]}}}",
-        "operateTime": 1772003207333,
-        "type": "row",
-        "version": 2377
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"sequence\":\"闲置\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"闲置\\\"]]]]\",\"filterFactor\":[\"闲置\"]}}}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"sequence\":\"目前查是离线\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"filterFactor\":[\"目前查是离线\"]}}}",
-        "operateTime": 1770990377587,
-        "type": "row",
-        "version": 2241
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"1ZiuS5Wlgz\",\"sequence\":\"1ZiuS5Wlgz\",\"data\":\"1ZiuS5Wlgz\",\"filterFactor\":\"1ZiuS5Wlgz\"}}}",
-        "oldValue": "{}",
-        "operateTime": 1770875502420,
-        "type": "row",
-        "version": 2162
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{}",
-        "oldValue": "{\"kFwb8br\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"8G9fnUmdKY\",\"sequence\":\"8G9fnUmdKY\",\"data\":\"8G9fnUmdKY\",\"filterFactor\":\"8G9fnUmdKY\"}}}",
-        "operateTime": 1770875501255,
-        "type": "row",
-        "version": 2161
-      },
-      {
-        "action": "updateRecords",
-        "newValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"sequence\":\"目前查是离线\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"目前查是离线\\\"]]]]\",\"filterFactor\":[\"目前查是离线\"]}}}",
-        "oldValue": "{\"ty1M3J4\":{\"dataType\":\"OBJECT\",\"value\":{\"sequence\":\"\",\"identifier\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"\\\"]]]]\",\"data\":\"[\\\"root\\\",{},[\\\"p\\\",{},[\\\"span\\\",{\\\"data-type\\\":\\\"text\\\"},[\\\"span\\\",{\\\"data-type\\\":\\\"leaf\\\"},\\\"\\\"]]]]\",\"filterFactor\":[\"\"]}}}",
-        "operateTime": 1770868083188,
-        "type": "row",
-        "version": 2139
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got 10 history record(s) for record 1015oH3OXy"
-}
dws aitable +record-primary-doc-create --base-id <base-id> --table-id <table-id> --field-id <field-id> --record-id <record-id>write
为记录创建主键文档(幂等),fieldId 须为 primaryDoc 类型
dws mcp aitable create_primary_doc --json '{"baseId":<..>, "tableId":<..>, "fieldId":<..>, "recordId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/create_primary_doc
输入 / CLI 输出
输入
dws aitable +record-primary-doc-create --base-id x --table-id x --field-id x --record-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldId": "x",
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "create_primary_doc"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "fieldId": "x",
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "create_primary_doc"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1150ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +record-primary-doc-create --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --record-id 999999999999 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"RESOLVE_DOC_ID_ERROR\",\"message\":\"Failed to resolve docId from baseId\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to create cell doc because baseId cannot be resolved to docId\",\"trace_id\":\"213ee25c17840998736256985e0944\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"RESOLVE_DOC_ID_ERROR\",\"message\":\"Failed to resolve docId from baseId\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to create cell doc because baseId cannot be resolved to docId\",\"trace_id\":\"213ee25c17840998736256985e0944\"}"
-  }
-}
dws aitable +record-primary-doc-get --base-id <base-id> --table-id <table-id> --record-id <record-id>read
查询记录关联的主键文档 nodeId
dws mcp aitable get_primary_doc --json '{"baseId":<..>, "tableId":<..>, "recordId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_primary_doc
输入 / CLI 输出
输入
dws aitable +record-primary-doc-get --base-id x --table-id x --record-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_primary_doc"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_primary_doc"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1344ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +record-primary-doc-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"-1\",\"message\":\"no record\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to query cell doc for record 1015oH3OXy in table hERWDMS\",\"trace_id\":\"2127d89817840997528393730e079c\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"-1\",\"message\":\"no record\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to query cell doc for record 1015oH3OXy in table hERWDMS\",\"trace_id\":\"2127d89817840997528393730e079c\"}"
-  }
-}
dws aitable +record-query --base-id <base-id> --table-id <table-id> --record-ids <record-ids> --field-ids <field-ids> --filters <filters> --sort <sort> --query <query> --limit <limit> --cursor <cursor>read
查询表格记录(按 ID 取 / 条件筛选 / 关键词 / 分页)
dws mcp aitable query_records --json '{"baseId":<..>, "tableId":<..>, "recordIds":<..>, "fieldIds":<..>, "filters":<..>, "sort":<..>, "keyword":<..>, "limit":<..>, "cursor":<..>}'lark base 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--filters JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +record-query --base-id x --table-id x --record-ids x --field-ids x --filters x --sort x --query x --limit 1 --cursor x --yes
stdout
(空)
stderr / error
--filters JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--filters JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1241ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +record-query --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-ids 1015oH3OXy --field-ids 01ZM8y7 --filters '{}' --sort '[]' --query '测试' --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "data": {},
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Successfully queried 0 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {},
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Successfully queried 0 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
dws aitable +record-query-empty --base-id <base-id> --table-id <table-id> --limit <limit> --cursor <cursor>read
扫描并过滤出完全没填用户字段的空行
dws mcp aitable query_empty_records --json '{"baseId":<..>, "tableId":<..>, "limit":<..>, "cursor":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/query_empty_records
输入 / CLI 输出
输入
dws aitable +record-query-empty --base-id x --table-id x --limit 1 --cursor x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "cursor": "x",
-    "limit": 1,
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "query_empty_records"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "cursor": "x",
-    "limit": 1,
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "query_empty_records"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2057ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +record-query-empty --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "data": {
-    "nextCursor": "4lLnbs69uY",
-    "records": []
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Scanned 10 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk, 0 of them are empty."
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "nextCursor": "4lLnbs69uY",
-    "records": []
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Scanned 10 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk, 0 of them are empty."
-}
dws aitable +record-share-links --base <base> --table <table> --record-ids <record-ids> --view-id <view-id>read复合/智能型
批量(可 >20 条)获取多维表记录分享链接:去重+分片+合并
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_record_share_url
输入 / CLI 输出
输入
dws aitable +record-share-links --base x --table x --record-ids x --view-id x --yes
stdout
{
-  "base": "x",
-  "batches": 1,
-  "items": [],
-  "table": "x",
-  "total": 1
-}
stderr / error
(空)
最终用于判定的返回
{
-  "base": "x",
-  "batches": 1,
-  "items": [],
-  "table": "x",
-  "total": 1
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1249ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +record-share-links --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table hERWDMS --record-ids 1015oH3OXy --view-id qvGDAH2 --yes --format json
真实 stdout
{
-  "base": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-  "batches": 1,
-  "items": [
-    {
-      "recordId": "1015oH3OXy",
-      "shareUrl": "https://alidocs.dingtalk.com/notable/record?dentryUuid=gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk&sheetId=hERWDMS&rowId=1015oH3OXy&viewId=qvGDAH2"
-    }
-  ],
-  "table": "hERWDMS",
-  "total": 1
-}
真实 stderr / error
(空)
真实返回
{
-  "base": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-  "batches": 1,
-  "items": [
-    {
-      "recordId": "1015oH3OXy",
-      "shareUrl": "https://alidocs.dingtalk.com/notable/record?dentryUuid=gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk&sheetId=hERWDMS&rowId=1015oH3OXy&viewId=qvGDAH2"
-    }
-  ],
-  "table": "hERWDMS",
-  "total": 1
-}
dws aitable +record-share-url --base-id <base-id> --table-id <table-id> --record-ids <record-ids> --view-id <view-id>read
按 recordId 批量获取记录分享链接,单次最多 20 条
dws mcp aitable get_record_share_url --json '{"baseId":<..>, "tableId":<..>, "recordIds":<..>, "viewId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_record_share_url
输入 / CLI 输出
输入
dws aitable +record-share-url --base-id x --table-id x --record-ids x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordIds": [
-      "x"
-    ],
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_record_share_url"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "recordIds": [
-      "x"
-    ],
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_record_share_url"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1130ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +record-share-url --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-ids 1015oH3OXy --view-id qvGDAH2 --yes --format json
真实 stdout
{
-  "data": {
-    "items": [
-      {
-        "recordId": "1015oH3OXy",
-        "shareUrl": "https://alidocs.dingtalk.com/notable/record?dentryUuid=gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\u0026sheetId=hERWDMS\u0026rowId=1015oH3OXy\u0026viewId=qvGDAH2"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got share urls for 1 record(s)"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "items": [
-      {
-        "recordId": "1015oH3OXy",
-        "shareUrl": "https://alidocs.dingtalk.com/notable/record?dentryUuid=gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\u0026sheetId=hERWDMS\u0026rowId=1015oH3OXy\u0026viewId=qvGDAH2"
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got share urls for 1 record(s)"
-}
dws aitable +record-update --base-id <base-id> --table-id <table-id> --records <records>write
批量更新记录(每条含 recordId 与 cells),单次最多 100 条
dws mcp aitable update_records --json '{"baseId":<..>, "tableId":<..>, "records":<..>}'lark base 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--records JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +record-update --base-id x --table-id x --records x --yes
stdout
(空)
stderr / error
--records JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--records JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1144ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +record-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[{"recordId":"recDWSREALTEST","cells":{}}]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_RECORDS\",\"message\":\"records must contain at least one writable record\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update records because no valid writable records remain for table DWSREALTESTNOSUCHID0000000000000. Skipped records with empty cells (1): recDWSREALTEST\",\"trace_id\":\"0bab027317840998747383236e090b\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_RECORDS\",\"message\":\"records must contain at least one writable record\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update records because no valid writable records remain for table DWSREALTESTNOSUCHID0000000000000. Skipped records with empty cells (1): recDWSREALTEST\",\"trace_id\":\"0bab027317840998747383236e090b\"}"
-  }
-}
dws aitable +record-upsert --base-id <base-id> --table-id <table-id> --records <records>write
按 records 是否带 recordId 自动拆分 create / update
dws mcp aitable record_upsert --json '{"baseId":<..>, "tableId":<..>, "records":<..>}'lark-cli base +record-upsert实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--records JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +record-upsert --base-id x --table-id x --records x --yes
stdout
(空)
stderr / error
--records JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--records JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1173ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +record-upsert --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMETER\",\"message\":\"records is required and must not be empty\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"records is required and must not be empty\",\"trace_id\":\"2106d98117840998759832182e087b\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMETER\",\"message\":\"records is required and must not be empty\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"records is required and must not be empty\",\"trace_id\":\"2106d98117840998759832182e087b\"}"
-  }
-}
dws aitable +resolve-base --name <name>read复合/智能型
按名称搜索多维表 Base 并解析出唯一 baseId(只读)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/search_bases;没有找到名称包含 x 的 Base
输入 / CLI 输出
输入
dws aitable +resolve-base --name x --yes
stdout
(空)
stderr / error
没有找到名称包含 x 的 Base
最终用于判定的返回
没有找到名称包含 x 的 Base
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1228ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +resolve-base --name 'Real共创版设备去向登记' --yes --format json
真实 stdout
{
-  "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-  "name": "Real共创版设备去向登记",
-  "resolved": true
-}
真实 stderr / error
(空)
真实返回
{
-  "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-  "name": "Real共创版设备去向登记",
-  "resolved": true
-}
dws aitable +resolve-table --base <base> --name <name>read复合/智能型
在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_tables;Base 内没有名称包含 x 的数据表
输入 / CLI 输出
输入
dws aitable +resolve-table --base x --name x --yes
stdout
(空)
stderr / error
Base 内没有名称包含 x 的数据表
最终用于判定的返回
Base 内没有名称包含 x 的数据表
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1425ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +resolve-table --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --name 'Mac Mini' --yes --format json
真实 stdout
{
-  "base": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-  "name": "Mac Mini",
-  "resolved": true,
-  "tableId": "hERWDMS"
-}
真实 stderr / error
(空)
真实返回
{
-  "base": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-  "name": "Mac Mini",
-  "resolved": true,
-  "tableId": "hERWDMS"
-}
dws aitable +role-create --base-id <base-id> --name <name> --role-type <role-type> --flow-type <flow-type> --sub-roles <sub-roles>write
在指定 Base 下创建自定义角色
dws mcp aitable create_role --json '{"baseId":<..>, "name":<..>, "roleType":<..>, "flowType":<..>, "subRoles":<..>}'lark-cli base +role-create实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--sub-roles JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +role-create --base-id x --name x --role-type x --flow-type x --sub-roles x --yes
stdout
(空)
stderr / error
--sub-roles JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--sub-roles JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1130ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +role-create --base-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to create role for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998770933580e090b\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to create role for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"0bab027317840998770933580e090b\"}"
-  }
-}
dws aitable +role-delete --base-id <base-id> --role-id <role-id>high-risk-write
删除 Base 下指定的自定义角色(不可逆)
dws mcp aitable delete_role --json '{"baseId":<..>, "roleId":<..>}'lark-cli base +role-delete已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/delete_role
输入 / CLI 输出
输入
dws aitable +role-delete --base-id x --role-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "roleId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "delete_role"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "roleId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "delete_role"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1150ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +role-delete --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"roleId is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete role because roleId is missing\",\"trace_id\":\"2106d98117840998782482701e089c\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"roleId is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete role because roleId is missing\",\"trace_id\":\"2106d98117840998782482701e089c\"}"
-  }
-}
dws aitable +role-get --base-id <base-id> --role-id <role-id>read
获取单个角色的完整配置
dws mcp aitable get_role --json '{"baseId":<..>, "roleId":<..>}'lark-cli base +role-get已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_role
输入 / CLI 输出
输入
dws aitable +role-get --base-id x --role-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "roleId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_role"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "roleId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_role"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1450ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +role-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --role-id x --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"roleId is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get role because roleId is missing\",\"trace_id\":\"2104a64c17840997542904838e0817\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"roleId is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get role because roleId is missing\",\"trace_id\":\"2104a64c17840997542904838e0817\"}"
-  }
-}
dws aitable +role-list --base-id <base-id>read
列出指定 Base 下的全部角色
dws mcp aitable list_roles --json '{"baseId":<..>}'lark-cli base +role-list已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/list_roles
输入 / CLI 输出
输入
dws aitable +role-list --base-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_roles"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_roles"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1483ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +role-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json
真实 stdout
{
-  "data": {
-    "defaultRole": {
-      "mode": 2
-    },
-    "enabled": true,
-    "roles": [
-      {
-        "name": "所有者",
-        "roleId": "owner",
-        "roleType": "5000",
-        "subRoles": [],
-        "system": true
-      },
-      {
-        "name": "管理员",
-        "roleId": "manager",
-        "roleType": "4000",
-        "subRoles": [],
-        "system": true
-      },
-      {
-        "name": "可查看角色",
-        "roleId": "10181777133",
-        "roleType": "custom",
-        "subRoles": [
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 255
-            },
-            "display": {
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置",
-              "targetTypeLabel": "仪表盘"
-            },
-            "targetId": "KY9tlWg5NEHgfs8WT6IO2",
-            "targetType": "dashboard"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "hERWDMS",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "k8d898q",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zyLRwwa",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "L53jNZz",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zTiHNDL",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "anMdihs",
-            "targetType": "sheet"
-          }
-        ],
-        "system": false
-      },
-      {
-        "name": "notable_biz_advanced_permission_role_editor",
-        "roleId": "10181777134",
-        "roleType": "system_editor",
-        "subRoles": [
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 255
-            },
-            "display": {
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置",
-              "targetTypeLabel": "仪表盘"
-            },
-            "targetId": "KY9tlWg5NEHgfs8WT6IO2",
-            "targetType": "dashboard"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "hERWDMS",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "k8d898q",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "zyLRwwa",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "L53jNZz",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "zTiHNDL",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "anMdihs",
-            "targetType": "sheet"
-          }
-        ],
-        "system": true
-      },
-      {
-        "name": "notable_biz_advanced_permission_role_reader",
-        "roleId": "10181777135",
-        "roleType": "system_reader",
-        "subRoles": [
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 255
-            },
-            "display": {
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置",
-              "targetTypeLabel": "仪表盘"
-            },
-            "targetId": "KY9tlWg5NEHgfs8WT6IO2",
-            "targetType": "dashboard"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "hERWDMS",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "k8d898q",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zyLRwwa",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "L53jNZz",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zTiHNDL",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "anMdihs",
-            "targetType": "sheet"
-          }
-        ],
-        "system": true
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Listed 5 roles for base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "defaultRole": {
-      "mode": 2
-    },
-    "enabled": true,
-    "roles": [
-      {
-        "name": "所有者",
-        "roleId": "owner",
-        "roleType": "5000",
-        "subRoles": [],
-        "system": true
-      },
-      {
-        "name": "管理员",
-        "roleId": "manager",
-        "roleType": "4000",
-        "subRoles": [],
-        "system": true
-      },
-      {
-        "name": "可查看角色",
-        "roleId": "10181777133",
-        "roleType": "custom",
-        "subRoles": [
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 255
-            },
-            "display": {
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置",
-              "targetTypeLabel": "仪表盘"
-            },
-            "targetId": "KY9tlWg5NEHgfs8WT6IO2",
-            "targetType": "dashboard"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "hERWDMS",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "k8d898q",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zyLRwwa",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "L53jNZz",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zTiHNDL",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "anMdihs",
-            "targetType": "sheet"
-          }
-        ],
-        "system": false
-      },
-      {
-        "name": "notable_biz_advanced_permission_role_editor",
-        "roleId": "10181777134",
-        "roleType": "system_editor",
-        "subRoles": [
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 255
-            },
-            "display": {
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置",
-              "targetTypeLabel": "仪表盘"
-            },
-            "targetId": "KY9tlWg5NEHgfs8WT6IO2",
-            "targetType": "dashboard"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "hERWDMS",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "k8d898q",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "zyLRwwa",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "L53jNZz",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "zTiHNDL",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "manage",
-            "display": {
-              "authLevelLabel": "可管理",
-              "permissionScopeNote": "可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断",
-              "targetTypeLabel": "数据表"
-            },
-            "targetId": "anMdihs",
-            "targetType": "sheet"
-          }
-        ],
-        "system": true
-      },
-      {
-        "name": "notable_biz_advanced_permission_role_reader",
-        "roleId": "10181777135",
-        "roleType": "system_reader",
-        "subRoles": [
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 255
-            },
-            "display": {
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置",
-              "targetTypeLabel": "仪表盘"
-            },
-            "targetId": "KY9tlWg5NEHgfs8WT6IO2",
-            "targetType": "dashboard"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "hERWDMS",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "k8d898q",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zyLRwwa",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "L53jNZz",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "zTiHNDL",
-            "targetType": "sheet"
-          },
-          {
-            "authLevel": "read",
-            "config": {
-              "actions": 268435455
-            },
-            "display": {
-              "actionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ],
-              "actionsNote": "actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作",
-              "authLevelLabel": "仅查看",
-              "permissionScopeNote": "仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作",
-              "targetTypeLabel": "数据表",
-              "viewActionsLabels": [
-                "新增视图",
-                "删除视图",
-                "修改视图"
-              ]
-            },
-            "targetId": "anMdihs",
-            "targetType": "sheet"
-          }
-        ],
-        "system": true
-      }
-    ]
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Listed 5 roles for base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
dws aitable +role-update --base-id <base-id> --role-id <role-id> --name <name> --role-type <role-type> --flow-type <flow-type> --sub-roles <sub-roles>write
按 PATCH 语义增量更新自定义角色
dws mcp aitable patch_role --json '{"baseId":<..>, "roleId":<..>, "name":<..>, "roleType":<..>, "flowType":<..>, "subRoles":<..>}'lark-cli base +role-update实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--sub-roles JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +role-update --base-id x --role-id x --name x --role-type x --flow-type x --sub-roles x --yes
stdout
(空)
stderr / error
--sub-roles JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--sub-roles JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1209ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +role-update --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"roleId is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to patch role because roleId is missing\",\"trace_id\":\"2132f5ca17840998794483634e08d8\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"roleId is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to patch role because roleId is missing\",\"trace_id\":\"2132f5ca17840998794483634e08d8\"}"
-  }
-}
dws aitable +section-create --base-id <base-id> --name <name> --parent-section-id <parent-section-id> --index <index>write
在指定 Base 下创建文件夹(组织 table / dashboard)
dws mcp aitable create_section --json '{"baseId":<..>, "name":<..>, "parentSectionId":<..>, "index":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/create_section
输入 / CLI 输出
输入
dws aitable +section-create --base-id x --name x --parent-section-id x --index 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "index": 1,
-    "name": "x",
-    "parentSectionId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "create_section"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "index": 1,
-    "name": "x",
-    "parentSectionId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "create_section"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1117ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +section-create --base-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --parent-section-id DWSREALTESTNOSUCHID0000000000000 --index 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to create section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998805556786e08d9\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to create section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998805556786e08d9\"}"
-  }
-}
dws aitable +section-delete --base-id <base-id> --section-id <section-id>high-risk-write
删除指定文件夹(不可逆)
dws mcp aitable delete_section --json '{"baseId":<..>, "sectionId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/delete_section
输入 / CLI 输出
输入
dws aitable +section-delete --base-id x --section-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "sectionId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "delete_section"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "sectionId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "delete_section"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1111ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +section-delete --base-id DWSREALTESTNOSUCHID0000000000000 --section-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998817023336e087a\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998817023336e087a\"}"
-  }
-}
dws aitable +section-list-empty --base-id <base-id>read
列出指定 Base 下所有没有子节点的空文件夹
dws mcp aitable list_empty_sections --json '{"baseId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/list_empty_sections
输入 / CLI 输出
输入
dws aitable +section-list-empty --base-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_empty_sections"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_empty_sections"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1202ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +section-list-empty --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json
真实 stdout
{
-  "data": {
-    "items": [],
-    "total": 0
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Found 0 empty sections in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "items": [],
-    "total": 0
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Found 0 empty sections in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
dws aitable +section-list-nodes --base-id <base-id>read
列出指定 Base 当前版本下的全部 nsheet 节点
dws mcp aitable list_nsheet_nodes --json '{"baseId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/list_nsheet_nodes
输入 / CLI 输出
输入
dws aitable +section-list-nodes --base-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_nsheet_nodes"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "list_nsheet_nodes"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=969ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +section-list-nodes --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json
真实 stdout
{
-  "data": {
-    "items": [
-      {
-        "nodeId": "L53jNZz",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "zTiHNDL",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "TG566ix",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "hERWDMS-HaFV1LW",
-        "nodeType": "form_view",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "KY9tlWg5NEHgfs8WT6IO2",
-        "nodeType": "dashboard",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "TWB385A",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "hERWDMS-lmeV1cb",
-        "nodeType": "form_view",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "anMdihs",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "qid57iq",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "k8d898q-mkGX58x",
-        "nodeType": "form_view",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "k8d898q",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "zyLRwwa",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "hERWDMS",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      }
-    ],
-    "total": 13
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Found 13 nsheet nodes in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "items": [
-      {
-        "nodeId": "L53jNZz",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "zTiHNDL",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "TG566ix",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "hERWDMS-HaFV1LW",
-        "nodeType": "form_view",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "KY9tlWg5NEHgfs8WT6IO2",
-        "nodeType": "dashboard",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "TWB385A",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "hERWDMS-lmeV1cb",
-        "nodeType": "form_view",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "anMdihs",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "qid57iq",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "k8d898q-mkGX58x",
-        "nodeType": "form_view",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "k8d898q",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "zyLRwwa",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      },
-      {
-        "nodeId": "hERWDMS",
-        "nodeType": "sheet",
-        "parentSectionId": ""
-      }
-    ],
-    "total": 13
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Found 13 nsheet nodes in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
-}
dws aitable +section-move-node --base-id <base-id> --node-id <node-id> --new-parent-section-id <new-parent-section-id> --target-index <target-index>write
把任意 nsheet 节点移动到目标文件夹下(可选调整位置)
dws mcp aitable move_nsheet_node --json '{"baseId":<..>, "nodeId":<..>, "newParentSectionId":<..>, "targetIndex":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/move_nsheet_node
输入 / CLI 输出
输入
dws aitable +section-move-node --base-id x --node-id x --new-parent-section-id x --target-index 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "newParentSectionId": "x",
-    "nodeId": "x",
-    "targetIndex": 1
-  },
-  "product": "aitable-helper",
-  "tool": "move_nsheet_node"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "newParentSectionId": "x",
-    "nodeId": "x",
-    "targetIndex": 1
-  },
-  "product": "aitable-helper",
-  "tool": "move_nsheet_node"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1186ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +section-move-node --base-id DWSREALTESTNOSUCHID0000000000000 --node-id DWSREALTESTNOSUCHID0000000000000 --new-parent-section-id DWSREALTESTNOSUCHID0000000000000 --target-index 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to move node for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998828667800e0920\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to move node for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998828667800e0920\"}"
-  }
-}
dws aitable +section-rename --base-id <base-id> --section-id <section-id> --new-name <new-name>write
重命名指定文件夹
dws mcp aitable rename_section --json '{"baseId":<..>, "sectionId":<..>, "newName":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/rename_section
输入 / CLI 输出
输入
dws aitable +section-rename --base-id x --section-id x --new-name x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "newName": "x",
-    "sectionId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "rename_section"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "newName": "x",
-    "sectionId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "rename_section"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1259ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +section-rename --base-id DWSREALTESTNOSUCHID0000000000000 --section-id DWSREALTESTNOSUCHID0000000000000 --new-name 'DWS shortcut 真实测试 20260715-151724' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to rename section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998840692547e08e2\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to rename section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998840692547e08e2\"}"
-  }
-}
dws aitable +section-reorder --base-id <base-id> --section-id <section-id> --target-index <target-index>write
在当前父文件夹下调整文件夹的展示顺序
dws mcp aitable reorder_section --json '{"baseId":<..>, "sectionId":<..>, "targetIndex":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/reorder_section
输入 / CLI 输出
输入
dws aitable +section-reorder --base-id x --section-id x --target-index 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "sectionId": "x",
-    "targetIndex": 1
-  },
-  "product": "aitable-helper",
-  "tool": "reorder_section"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "sectionId": "x",
-    "targetIndex": 1
-  },
-  "product": "aitable-helper",
-  "tool": "reorder_section"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1193ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +section-reorder --base-id DWSREALTESTNOSUCHID0000000000000 --section-id DWSREALTESTNOSUCHID0000000000000 --target-index 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to reorder section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998853157434e08d9\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to reorder section for base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998853157434e08d9\"}"
-  }
-}
dws aitable +table-delete --base-id <base-id> --table-id <table-id> --reason <reason>high-risk-write
删除指定数据表(不可逆)
dws mcp aitable delete_table --json '{"baseId":<..>, "tableId":<..>, "reason":<..>}'lark-cli base +table-delete已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/delete_table
输入 / CLI 输出
输入
dws aitable +table-delete --base-id x --table-id x --reason x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "reason": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_table"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "reason": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_table"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1199ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +table-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Specified base does not exist, has been deleted, or is inaccessible\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete table because base does not exist or is inaccessible\",\"trace_id\":\"2132f5ca17840998865048372e0852\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Specified base does not exist, has been deleted, or is inaccessible\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete table because base does not exist or is inaccessible\",\"trace_id\":\"2132f5ca17840998865048372e0852\"}"
-  }
-}
dws aitable +table-get --base-id <base-id> --table-ids <table-ids>read
批量获取指定数据表的表级信息、字段目录与视图目录
dws mcp aitable get_tables --json '{"baseId":<..>, "tableIds":<..>}'lark-cli base +table-get已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_tables
输入 / CLI 输出
输入
dws aitable +table-get --base-id x --table-ids x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableIds": [
-      "x"
-    ]
-  },
-  "product": "aitable",
-  "tool": "get_tables"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableIds": [
-      "x"
-    ]
-  },
-  "product": "aitable",
-  "tool": "get_tables"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1301ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +table-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-ids x --yes --format json
真实 stdout
{
-  "data": {
-    "tables": []
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got 0 tables from base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk. Requested tableIds not returned: x. URL rules: AI 表格数据表访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}; AI 表格视图访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "tables": []
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Got 0 tables from base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk. Requested tableIds not returned: x. URL rules: AI 表格数据表访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}; AI 表格视图访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。"
-}
dws aitable +table-update --base-id <base-id> --table-id <table-id> --name <name> --description <description> --record-name-key <record-name-key>write
更新数据表名称 / 备注 / 行命名规则
dws mcp aitable update_table --json '{"baseId":<..>, "tableId":<..>, "newTableName":<..>, "description":<..>, "recordNameKey":<..>}'lark-cli base +table-update已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/update_table
输入 / CLI 输出
输入
dws aitable +table-update --base-id x --table-id x --name x --description x --record-name-key x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "description": "x",
-    "newTableName": "x",
-    "recordNameKey": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "update_table"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "description": "x",
-    "newTableName": "x",
-    "recordNameKey": "x",
-    "tableId": "x"
-  },
-  "product": "aitable",
-  "tool": "update_table"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1249ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +table-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --record-name-key task --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update table DWSREALTESTNOSUCHID0000000000000 in base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998877246229e087f\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"404\",\"message\":\"getDentryDTO returns null\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update table DWSREALTESTNOSUCHID0000000000000 in base DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998877246229e087f\"}"
-  }
-}
dws aitable +template-search --query <query> --limit <limit> --cursor <cursor>read
按名称关键词搜索 AI 表格模板
dws mcp aitable <tool>lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/search_templates
输入 / CLI 输出
输入
dws aitable +template-search --query x --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "templates": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "templates": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=953ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +template-search --query '测试' --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 2,
-  "templates": [
-    {
-      "templateId": "513",
-      "templateName": "随堂小测试"
-    },
-    {
-      "templateId": "425",
-      "templateName": "随堂小测试"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 2,
-  "templates": [
-    {
-      "templateId": "513",
-      "templateName": "随堂小测试"
-    },
-    {
-      "templateId": "425",
-      "templateName": "随堂小测试"
-    }
-  ]
-}
dws aitable +view-delete --base-id <base-id> --table-id <table-id> --view-id <view-id>high-risk-write
删除指定视图(不可逆)
dws mcp aitable delete_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>}'lark-cli base +view-delete已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/delete_view
输入 / CLI 输出
输入
dws aitable +view-delete --base-id x --table-id x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable",
-  "tool": "delete_view"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1236ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +view-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998889051250e08df\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to delete view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998889051250e08df\"}"
-  }
-}
dws aitable +view-duplicate --base-id <base-id> --table-id <table-id> --view-id <view-id> --new-name <new-name>write
复制视图,生成配置相同的新视图
dws mcp aitable duplicate_view --json '{"baseId":<..>, "tableId":<..>, "sourceViewId":<..>, "newViewName":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/duplicate_view
输入 / CLI 输出
输入
dws aitable +view-duplicate --base-id x --table-id x --view-id x --new-name x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "newViewName": "x",
-    "sourceViewId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "duplicate_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "newViewName": "x",
-    "sourceViewId": "x",
-    "tableId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "duplicate_view"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1160ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +view-duplicate --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --new-name 'DWS shortcut 真实测试 20260715-151724' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to duplicate view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998901765467e0853\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to duplicate view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2132f5ca17840998901765467e0853\"}"
-  }
-}
dws aitable +view-get --base-id <base-id> --table-id <table-id> --view-ids <view-ids>read
获取视图完整信息(列顺序、筛选、排序、分组等)
dws mcp aitable <tool>lark-cli base +view-get已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable/get_views
输入 / CLI 输出
输入
dws aitable +view-get --base-id x --table-id x --view-ids x --yes
stdout
{
-  "count": 0,
-  "views": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "views": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1137ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +view-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-ids qvGDAH2 --yes --format json
真实 stdout
{
-  "count": 1,
-  "views": [
-    {
-      "viewId": "qvGDAH2",
-      "viewName": "Mac Mini",
-      "viewType": "Grid"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 1,
-  "views": [
-    {
-      "viewId": "qvGDAH2",
-      "viewName": "Mac Mini",
-      "viewType": "Grid"
-    }
-  ]
-}
dws aitable +view-get-frozen-cols --base-id <base-id> --table-id <table-id> --view-id <view-id>read
获取视图当前冻结的左侧列数
dws mcp aitable get_frozen_columns_of_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_frozen_columns_of_view
输入 / CLI 输出
输入
dws aitable +view-get-frozen-cols --base-id x --table-id x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_frozen_columns_of_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_frozen_columns_of_view"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1097ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +view-get-frozen-cols --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id qvGDAH2 --yes --format json
真实 stdout
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "count": 1,
-    "tableId": "hERWDMS",
-    "viewId": "qvGDAH2"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Frozen column count of view 'Mac Mini' is 1"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "count": 1,
-    "tableId": "hERWDMS",
-    "viewId": "qvGDAH2"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "Frozen column count of view 'Mac Mini' is 1"
-}
dws aitable +view-get-lock --base-id <base-id> --table-id <table-id> --view-id <view-id>read
获取视图锁定状态
dws mcp aitable get_view_lock_status --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_view_lock_status
输入 / CLI 输出
输入
dws aitable +view-get-lock --base-id x --table-id x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_view_lock_status"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_view_lock_status"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1063ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +view-get-lock --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id qvGDAH2 --yes --format json
真实 stdout
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "locked": false,
-    "tableId": "hERWDMS",
-    "viewId": "qvGDAH2"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "View 'Mac Mini' is unlocked"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "locked": false,
-    "tableId": "hERWDMS",
-    "viewId": "qvGDAH2"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "View 'Mac Mini' is unlocked"
-}
dws aitable +view-get-row-height --base-id <base-id> --table-id <table-id> --view-id <view-id>read
获取视图单元格行高(像素)
dws mcp aitable get_cell_height_of_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_cell_height_of_view
输入 / CLI 输出
输入
dws aitable +view-get-row-height --base-id x --table-id x --view-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_cell_height_of_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_cell_height_of_view"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1284ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test aitable +view-get-row-height --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id qvGDAH2 --yes --format json
真实 stdout
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "tableId": "hERWDMS",
-    "viewId": "qvGDAH2"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "View 'Mac Mini' has no explicit cell height"
-}
真实 stderr / error
(空)
真实返回
{
-  "data": {
-    "baseId": "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk",
-    "tableId": "hERWDMS",
-    "viewId": "qvGDAH2"
-  },
-  "error": {},
-  "meta": {},
-  "status": "success",
-  "success": true,
-  "summary": "View 'Mac Mini' has no explicit cell height"
-}
dws aitable +view-lock --base-id <base-id> --table-id <table-id> --view-id <view-id> --off <off>write
锁定视图(默认)或解锁(--off)
dws mcp aitable lock_or_unlock_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "action":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/lock_or_unlock_view
输入 / CLI 输出
输入
dws aitable +view-lock --base-id x --table-id x --view-id x --off --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "action": "unlock",
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "lock_or_unlock_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "action": "unlock",
-    "baseId": "x",
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "lock_or_unlock_view"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1215ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +view-lock --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --off --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to lock_or_unlock view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998913717245e0941\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to lock_or_unlock view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998913717245e0941\"}"
-  }
-}
dws aitable +view-set-fill-color-rule --base-id <base-id> --table-id <table-id> --view-id <view-id> --json <json>write
全量覆盖 Grid 视图的条件填色规则(传 '[]' 清空)
dws mcp aitable set_view_fill_color_rule --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "conditionalFormats":<..>}'lark base 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--json JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +view-set-fill-color-rule --base-id x --table-id x --view-id x --json x --yes
stdout
(空)
stderr / error
--json JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--json JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1046ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +view-set-fill-color-rule --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --json '{}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"conditionalFormats is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set fill color rule because conditionalFormats is missing\",\"trace_id\":\"2106d98117840998924181709e08df\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_PARAMS\",\"message\":\"conditionalFormats is required\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set fill color rule because conditionalFormats is missing\",\"trace_id\":\"2106d98117840998924181709e08df\"}"
-  }
-}
dws aitable +view-set-frozen-cols --base-id <base-id> --table-id <table-id> --view-id <view-id> --count <count>write
设置视图冻结列数(0 表示取消冻结)
dws mcp aitable set_frozen_columns_of_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "count":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/set_frozen_columns_of_view
输入 / CLI 输出
输入
dws aitable +view-set-frozen-cols --base-id x --table-id x --view-id x --count 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "count": 1,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "set_frozen_columns_of_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "count": 1,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "set_frozen_columns_of_view"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1149ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +view-set-frozen-cols --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --count 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set frozen columns for view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998935988648e08de\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set frozen columns for view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998935988648e08de\"}"
-  }
-}
dws aitable +view-set-row-height --base-id <base-id> --table-id <table-id> --view-id <view-id> --cell-height <cell-height>write
设置视图单元格行高(像素,合法档位 32/56/88/128)
dws mcp aitable set_cell_height_of_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "cellHeight":<..>}'lark base 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/set_cell_height_of_view
输入 / CLI 输出
输入
dws aitable +view-set-row-height --base-id x --table-id x --view-id x --cell-height 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "cellHeight": 1,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "set_cell_height_of_view"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "cellHeight": 1,
-    "tableId": "x",
-    "viewId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "set_cell_height_of_view"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1345ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +view-set-row-height --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --cell-height 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set cell height for view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998949428245e08b8\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to set cell height for view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"2106d98117840998949428245e08b8\"}"
-  }
-}
dws aitable +view-update --base-id <base-id> --table-id <table-id> --view-id <view-id> --name <name> --desc <desc> --config <config>write
更新视图名称 / 描述 / 配置(visibleFieldIds、filter、sort、group 等)
dws mcp aitable update_view --json '{"baseId":<..>, "tableId":<..>, "viewId":<..>, "newViewName":<..>, "viewDescription":<..>, "config":<..>}'lark base 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--desc JSON 解析失败: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws aitable +view-update --base-id x --table-id x --view-id x --name x --desc x --config x --yes
stdout
(空)
stderr / error
--desc JSON 解析失败: invalid character 'x' looking for beginning of value
最终用于判定的返回
--desc JSON 解析失败: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1331ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +view-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --desc '{}' --config '{}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998961487395e087f\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"INVALID_BASE_ID\",\"message\":\"baseId cannot be resolved to docId\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to update view DWSREALTESTNOSUCHID0000000000000\",\"trace_id\":\"213ee25c17840998961487395e087f\"}"
-  }
-}
dws aitable +workflow-disable --base-id <base-id> --workflow-id <workflow-id>high-risk-write
禁用指定 Base 中的自动化工作流(影响业务自动化)
dws mcp aitable disable_workflow --json '{"baseId":<..>, "workflowId":<..>}'lark-cli base +workflow-disable已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/disable_workflow
输入 / CLI 输出
输入
dws aitable +workflow-disable --base-id x --workflow-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "workflowId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "disable_workflow"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "workflowId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "disable_workflow"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1320ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +workflow-disable --base-id DWSREALTESTNOSUCHID0000000000000 --workflow-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to disable workflow because base 'DWSREALTESTNOSUCHID0000000000000' could not be found\",\"trace_id\":\"213ee25c17840998975738372e087e\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to disable workflow because base 'DWSREALTESTNOSUCHID0000000000000' could not be found\",\"trace_id\":\"213ee25c17840998975738372e087e\"}"
-  }
-}
dws aitable +workflow-enable --base-id <base-id> --workflow-id <workflow-id>write
启用指定 Base 中的自动化工作流
dws mcp aitable enable_workflow --json '{"baseId":<..>, "workflowId":<..>}'lark-cli base +workflow-enable已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/enable_workflow
输入 / CLI 输出
输入
dws aitable +workflow-enable --base-id x --workflow-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "workflowId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "enable_workflow"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "workflowId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "enable_workflow"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1171ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺 AI 表格真实 fixture / 需真实 fixture,非 CLI 可直接修
-AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。
真实输入
/private/tmp/dws-real-test aitable +workflow-enable --base-id DWSREALTESTNOSUCHID0000000000000 --workflow-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to enable workflow because base 'DWSREALTESTNOSUCHID0000000000000' could not be found\",\"trace_id\":\"0bab027317840998987508899e094d\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"BASE_NOT_FOUND\",\"message\":\"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid\",\"retryable\":false,\"type\":\"INPUT_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to enable workflow because base 'DWSREALTESTNOSUCHID0000000000000' could not be found\",\"trace_id\":\"0bab027317840998987508899e094d\"}"
-  }
-}
dws aitable +workflow-get --base-id <base-id> --workflow-id <workflow-id>read
获取单个自动化工作流的详细信息
dws mcp aitable get_workflow --json '{"baseId":<..>, "workflowId":<..>}'lark-cli base +workflow-get已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/get_workflow
输入 / CLI 输出
输入
dws aitable +workflow-get --base-id x --workflow-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "workflowId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_workflow"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "baseId": "x",
-    "workflowId": "x"
-  },
-  "product": "aitable-helper",
-  "tool": "get_workflow"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1535ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +workflow-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --workflow-id DWSREALREADNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"GET_WORKFLOW_ERROR\",\"message\":\"调用远程服务业务异常\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get workflow in base 'gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk' due to an unexpected error\",\"trace_id\":\"2104a64c17840997556363676e08ee\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"GET_WORKFLOW_ERROR\",\"message\":\"调用远程服务业务异常\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to get workflow in base 'gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk' due to an unexpected error\",\"trace_id\":\"2104a64c17840997556363676e08ee\"}"
-  }
-}
dws aitable +workflow-list --base-id <base-id> --limit <limit> --offset <offset>read
列出指定 Base 中的自动化工作流(分页)
dws mcp aitable <tool>lark-cli base +workflow-list已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;aitable-helper/list_workflows
输入 / CLI 输出
输入
dws aitable +workflow-list --base-id x --limit 1 --offset 1 --yes
stdout
{
-  "count": 0,
-  "workflows": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "workflows": []
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1540ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
后端 / MCP 内部错误 / 优先后端 / MCP 排查
-后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。
真实输入
/private/tmp/dws-real-test aitable +workflow-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --limit 10 --offset 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"LIST_WORKFLOWS_ERROR\",\"message\":\"biz error\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to list workflows in base 'gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk' due to an unexpected error\",\"trace_id\":\"2127d89817840997572427879e075d\"}"
-  }
-}
真实返回
{
-  "error": {
-    "category": "internal",
-    "code": 1,
-    "message": "[MCP_TOOL_ERROR] {\"data\":{},\"error\":{\"code\":\"LIST_WORKFLOWS_ERROR\",\"message\":\"biz error\",\"retryable\":true,\"type\":\"SYSTEM_ERROR\"},\"meta\":{},\"status\":\"error\",\"success\":true,\"summary\":\"Failed to list workflows in base 'gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk' due to an unexpected error\",\"trace_id\":\"2127d89817840997572427879e075d\"}"
-  }
-}
-

attendance · 35 个 · 钉钉特有 · lark 无对应

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws attendance +boss-check --plan-id <plan-id> --result-id <result-id> --time <time> --result <result> --absent-min <absent-min> --remark <remark>write
BOSS 改签打卡记录(管理员修改打卡时间/结果)
dws mcp attendance boss_check --json '{"BossCheckMcpRequest":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/boss_check
输入 / CLI 输出
输入
dws attendance +boss-check --plan-id x --result-id x --time x --result Normal --absent-min 1 --remark x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "BossCheckMcpRequest": {
-      "absentMin": 1,
-      "bossCheckTime": "x",
-      "remark": "x",
-      "result": "Normal",
-      "resultId": "x"
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "boss_check"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "BossCheckMcpRequest": {
-      "absentMin": 1,
-      "bossCheckTime": "x",
-      "remark": "x",
-      "result": "Normal",
-      "resultId": "x"
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "boss_check"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1136ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +boss-check --plan-id 999999999999 --result-id 999999999999 --time 2026-07-22T10:00:00+08:00 --result Normal --absent-min 1 --remark 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "C0002",
-    "server_key": "attendance-wukong",
-    "trace_id": "213ee25c17840998998942184e087d"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "C0002",
-    "server_key": "attendance-wukong",
-    "trace_id": "213ee25c17840998998942184e087d"
-  }
-}
dws attendance +check-record --users <users> --start <start> --end <end>read
查询用户打卡流水(打卡时间/地点/定位方式)
dws mcp attendance query_check_record --json '{"QueryCheckRecordRequest":<..>, "userIds":<..>, "checkDateFrom":<..>, "checkDateTo":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws attendance +check-record --users x --start x --end x --yes
stdout
(空)
stderr / error
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
最终用于判定的返回
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=998ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +check-record --users 202397 --start 2026-07-14 --end 2026-07-15 --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
dws attendance +check-result --users <users> --start <start> --end <end> --offset <offset> --limit <limit>read
查询用户打卡结果(迟到/早退/缺卡等)
dws mcp attendance query_check_result --json '{"QueryCheckResultRequest":<..>, "userIds":<..>, "workDateFrom":<..>, "workDateTo":<..>, "offset":<..>, "limit":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws attendance +check-result --users x --start x --end x --offset 1 --limit 1 --yes
stdout
(空)
stderr / error
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
最终用于判定的返回
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1134ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +check-result --users 202397 --start 2026-07-14 --end 2026-07-15 --offset 1 --limit 10 --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
dws attendance +create-class --name <name> --owner <owner> --class-vo <class-vo>write
创建班次(checkTime 用 HH:mm,自动转时间戳)
dws mcp attendance create_class_setting --json '{"TopAtClassVO":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --class-vo JSON: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws attendance +create-class --name x --owner x --class-vo x --yes
stdout
(空)
stderr / error
invalid --class-vo JSON: invalid character 'x' looking for beginning of value
最终用于判定的返回
invalid --class-vo JSON: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1148ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +create-class --name 'DWS shortcut 真实测试 20260715-151724' --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --class-vo '{"sections":[{"times":[{"checkType":"OnDuty","checkTime":"09:00","across":0},{"checkType":"OffDuty","checkTime":"18:00","across":0}]}]}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "0bab027317840999009926838e090b"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "0bab027317840999009926838e090b"
-  }
-}
dws attendance +create-group --name <name> --type <type> --owner <owner> --group-vo <group-vo>write
创建考勤组(复杂子对象用 --group-vo JSON 传入)
dws mcp attendance create_group_setting --json '{"groupVO":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --group-vo JSON: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws attendance +create-group --name x --type FIXED --owner x --group-vo x --yes
stdout
(空)
stderr / error
invalid --group-vo JSON: invalid character 'x' looking for beginning of value
最终用于判定的返回
invalid --group-vo JSON: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1201ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +create-group --name 'DWS shortcut 真实测试 20260715-151724' --type FIXED --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --group-vo '{"defaultClassId":999999999999,"workDayClassList":[999999999999,999999999999,999999999999,999999999999,999999999999,0,0]}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "2106d98117840999021121258e08b8"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "2106d98117840999021121258e08b8"
-  }
-}
dws attendance +get-adjustment-rule --adjustment-id <adjustment-id>read
根据补卡规则主键 ID 查询补卡规则详情
dws mcp attendance get_adjustment_rule_detail --json '{"adjustmentId":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_adjustment_rule_detail
输入 / CLI 输出
输入
dws attendance +get-adjustment-rule --adjustment-id 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "adjustmentId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_adjustment_rule_detail"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "adjustmentId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_adjustment_rule_detail"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=950ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-adjustment-rule --adjustment-id 1 --yes --format json
真实 stdout
{
-  "success": true,
-  "有效期类型": "工作日"
-}
真实 stderr / error
(空)
真实返回
{
-  "success": true,
-  "有效期类型": "工作日"
-}
dws attendance +get-approve-template --type <type>read
查询补卡/请假/加班/外出/出差审批提交链接
dws mcp attendance query_at_approve_template --json '{"OUT":<..>, "approveType":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);无效的审批类型: x,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT
输入 / CLI 输出
输入
dws attendance +get-approve-template --type x --yes
stdout
(空)
stderr / error
无效的审批类型: x,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT
最终用于判定的返回
无效的审批类型: x,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1080ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-approve-template --type leave --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [
-    {
-      "approveType": "LEAVE",
-      "formName": "请假",
-      "processCode": "PROC-940F2B1F-845D-4F39-A92E-FA5F45D1BF87",
-      "submitUrl": "dingtalk://dingtalkclient/action/openapp?app_id=-4\u0026container_type=work_platform\u0026corpid=ding8196cd9a2b2405da24f2f5cc6abecb85\u0026ddtab=true\u0026redirect_type=jump\u0026redirect_url=https%3A%2F%2Faflow.dingtalk.com%2Fdingtalk%2Fmobile%2Fhomepage.htm%3FappUuid%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26backcontrol%3Dfalse%26corpid%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26dd_progress%3Dfalse%26dd_share%3Dfalse%26ddtab%3Dtrue%26showmenu%3Dfalse%23%2Fcustom%3Fpcredirect%3Dself%26processCode%3DPROC-940F2B1F-845D-4F39-A92E-FA5F45D1BF87"
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [
-    {
-      "approveType": "LEAVE",
-      "formName": "请假",
-      "processCode": "PROC-940F2B1F-845D-4F39-A92E-FA5F45D1BF87",
-      "submitUrl": "dingtalk://dingtalkclient/action/openapp?app_id=-4\u0026container_type=work_platform\u0026corpid=ding8196cd9a2b2405da24f2f5cc6abecb85\u0026ddtab=true\u0026redirect_type=jump\u0026redirect_url=https%3A%2F%2Faflow.dingtalk.com%2Fdingtalk%2Fmobile%2Fhomepage.htm%3FappUuid%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26backcontrol%3Dfalse%26corpid%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26dd_progress%3Dfalse%26dd_share%3Dfalse%26ddtab%3Dtrue%26showmenu%3Dfalse%23%2Fcustom%3Fpcredirect%3Dself%26processCode%3DPROC-940F2B1F-845D-4F39-A92E-FA5F45D1BF87"
-    }
-  ],
-  "success": true
-}
dws attendance +get-checkin-record --operator-corp-id <operator-corp-id> --operator-staff-id <operator-staff-id> --staff-ids <staff-ids> --start <start> --end <end>read
查询指定员工一段时间内的签到记录
dws mcp attendance get_checkin_record --json '{"QueryMcpUserRecordRequest":<..>, "operatorCorpId":<..>, "operatorStaffId":<..>, "staffIds":<..>, "startTime":<..>, "endTime":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws attendance +get-checkin-record --operator-corp-id x --operator-staff-id x --staff-ids x --start x --end x --yes
stdout
(空)
stderr / error
--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
最终用于判定的返回
--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1116ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-checkin-record --operator-corp-id x --operator-staff-id 202397 --staff-ids 202397 --start '2026-07-15 10:00:00' --end '2026-07-15 16:00:00' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "result": {
-    "list": []
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "result": {
-    "list": []
-  },
-  "success": true
-}
dws attendance +get-class --class-id <class-id>read
根据班次 ID 查询班次详情
dws mcp attendance get_class_detail --json '{"classId":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_class_detail
输入 / CLI 输出
输入
dws attendance +get-class --class-id 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "classId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_class_detail"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "classId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_class_detail"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1492ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test attendance +get-class --class-id 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "rule not found",
-    "server_key": "attendance-wukong",
-    "trace_id": "2127d89817840997588238831e0757"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "rule not found",
-    "server_key": "attendance-wukong",
-    "trace_id": "2127d89817840997588238831e0757"
-  }
-}
dws attendance +get-global-setting --setting-scene <setting-scene> --scope <scope>read
查询全局规则设置(仅管理员)
dws mcp attendance query_global_setting --json '{"RuleMcpQueryGlobalSettingRequest":<..>, "settingScene":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/query_global_setting
输入 / CLI 输出
输入
dws attendance +get-global-setting --setting-scene checkRemind --scope 企业 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "RuleMcpQueryGlobalSettingRequest": {
-      "settingScene": "checkRemind"
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "query_global_setting"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "RuleMcpQueryGlobalSettingRequest": {
-      "settingScene": "checkRemind"
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "query_global_setting"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1635ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test attendance +get-global-setting --setting-scene checkRemind --scope '企业' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "NO_PERMISSION",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840997604718062e085e"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "NO_PERMISSION",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840997604718062e085e"
-  }
-}
dws attendance +get-group --group-id <group-id>read
根据考勤组 ID 查询考勤组全量信息
dws mcp attendance get_group_detail --json '{"groupId":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_group_detail
输入 / CLI 输出
输入
dws attendance +get-group --group-id 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "groupId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_group_detail"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "groupId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_group_detail"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1568ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test attendance +get-group --group-id 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "rule not found",
-    "server_key": "attendance-wukong",
-    "trace_id": "0b5deb3217840997620512305e08ef"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "rule not found",
-    "server_key": "attendance-wukong",
-    "trace_id": "0b5deb3217840997620512305e08ef"
-  }
-}
dws attendance +get-group-filtered --group-id <group-id> --member <member> --position <position> --wifi <wifi> --bles <bles>read
按需查询考勤组成员/打卡地址/蓝牙/Wifi 子集
dws mcp attendance get_group_filtered_detail --json '{"groupId":<..>, "GroupResultFilter":<..>, "includeMember":<..>, "includePosition":<..>, "includeWifi":<..>, "includeBles":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_group_filtered_detail
输入 / CLI 输出
输入
dws attendance +get-group-filtered --group-id 1 --member --position --wifi --bles --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "GroupResultFilter": {
-      "includeBles": true,
-      "includeMember": true,
-      "includePosition": true,
-      "includeWifi": true
-    },
-    "groupId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_group_filtered_detail"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "GroupResultFilter": {
-      "includeBles": true,
-      "includeMember": true,
-      "includePosition": true,
-      "includeWifi": true
-    },
-    "groupId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_group_filtered_detail"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1746ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test attendance +get-group-filtered --group-id 1 --member --position --wifi --bles --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "rule not found",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840997638062468e08c7"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "rule not found",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840997638062468e08c7"
-  }
-}
dws attendance +get-leave-balance --users <users> --leave-code <leave-code>read
查询指定员工的假期余额
dws mcp attendance get_leave_balance_quota --json '{"targetUserIds":<..>, "McpLeaveBalanceRequest":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_leave_balance_quota
输入 / CLI 输出
输入
dws attendance +get-leave-balance --users x --leave-code x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "McpLeaveBalanceRequest": {
-      "leaveCode": "x",
-      "targetUserIds": [
-        "x"
-      ]
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "get_leave_balance_quota"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "McpLeaveBalanceRequest": {
-      "leaveCode": "x",
-      "targetUserIds": [
-        "x"
-      ]
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "get_leave_balance_quota"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1479ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +get-leave-balance --users 202397 --leave-code 731ed089-62ff-4734-a6c7-3c8fcc8294fc --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "2002",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840997652901307e081a"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "2002",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840997652901307e081a"
-  }
-}
dws attendance +get-leave-records --user <user> --leave-code <leave-code> --start <start> --end <end>read
查询指定员工的假期余额变更记录
dws mcp attendance get_leave_balance_records_v2 --json '{"targetUserId":<..>, "startTimeStamp":<..>, "endTimeStamp":<..>, "McpLeaveRecordRequest":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws attendance +get-leave-records --user x --leave-code x --start x --end x --yes
stdout
(空)
stderr / error
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
最终用于判定的返回
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1009ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-leave-records --user 202397 --leave-code 731ed089-62ff-4734-a6c7-3c8fcc8294fc --start 2026-07-14 --end 2026-07-15 --yes --format json
真实 stdout
{
-  "errorCode": null,
-  "errorMsg": null,
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "errorCode": null,
-  "errorMsg": null,
-  "result": [],
-  "success": true
-}
dws attendance +get-overtime-rule --overtime-id <overtime-id>read
根据加班规则主键 ID 查询加班规则详情
dws mcp attendance get_overtime_rule_detail --json '{"overtimeId":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_overtime_rule_detail
输入 / CLI 输出
输入
dws attendance +get-overtime-rule --overtime-id 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "overtimeId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_overtime_rule_detail"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "overtimeId": 1
-  },
-  "product": "attendance-wukong",
-  "tool": "get_overtime_rule_detail"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=961ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-overtime-rule --overtime-id 1 --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [
-    {
-      "advanceSettingDays": [],
-      "content": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "durationSettings": {
-        "holiday": {
-          "calcType": 1,
-          "durationType": 1,
-          "holidayPlanOvertimeRedress": null,
-          "holidayPlanOvertimeRedressBy": null,
-          "holidayPlanVacationRate": null,
-          "overtimeRedress": true,
-          "overtimeRedressBy": "vacation",
-          "roundType": null,
-          "settings": {},
-          "skipTime": "none",
-          "skipTimeByDurations": [],
-          "skipTimeByFrames": [],
-          "stepType": null,
-          "stepValue": null,
-          "vacationRate": "1.0",
-          "warningSettings": [],
-          "workMinutesPerDay": null
-        },
-        "restDay": {
-          "calcType": 1,
-          "durationType": 1,
-          "holidayPlanOvertimeRedress": null,
-          "holidayPlanOvertimeRedressBy": null,
-          "holidayPlanVacationRate": null,
-          "overtimeRedress": true,
-          "overtimeRedressBy": "vacation",
-          "roundType": null,
-          "settings": {},
-          "skipTime": "none",
-          "skipTimeByDurations": [],
-          "skipTimeByFrames": [],
-          "stepType": null,
-          "stepValue": null,
-          "vacationRate": "1.0",
-          "warningSettings": [],
-          "workMinutesPerDay": null
-        },
-        "workDay": {
-          "calcType": 1,
-          "durationType": 1,
-          "holidayPlanOvertimeRedress": null,
-          "holidayPlanOvertimeRedressBy": null,
-          "holidayPlanVacationRate": null,
-          "overtimeRedress": true,
-          "overtimeRedressBy": "vacation",
-          "roundType": null,
-          "settings": {
-            "calcTypeBetweenDutyWorktime": null,
-            "durationTypeBetweenDutyWorktime": null,
-            "dutyWorktimeLieuTypeVacationCode": null,
-            "dutyWorktimeOvertimeRedress": null,
-            "dutyWorktimeOvertimeRedressBy": null,
-            "dutyWorktimeVacationRate": null,
-            "enableAfterOffDuty": true,
-            "enableBeforeOnDuty": true,
-            "enableBetweenDuty": true,
-            "enableBetweenDutyWorktime": null,
-            "startTime": null,
-            "timeUnit": null,
-            "timeUnitAfterOffDuty": 30,
-            "timeUnitBeforeOnDuty": 30,
-            "timeUnitBetweenDuty": 0,
-            "timeUnitBetweenDutyWorktime": null
-          },
-          "skipTime": "none",
-          "skipTimeByDurations": [],
-          "skipTimeByFrames": [],
-          "stepType": null,
-          "stepValue": null,
-          "vacationRate": "1.0",
-          "warningSettings": [],
-          "workMinutesPerDay": null
-        }
-      },
-      "groupIdAndNames": [],
-      "id": 941615052,
-      "isDefault": null,
-      "name": "以审批时间计算加班",
-      "overtimeCheckSetting": {},
-      "overtimeDivisions": [],
-      "owner": null,
-      "ownerList": [],
-      "roundType": "floor",
-      "scopes": null,
-      "settingId": 941615052,
-      "stepType": 0,
-      "stepValue": "1.0",
-      "warningSettings": [],
-      "workMinutesPerDay": 480
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [
-    {
-      "advanceSettingDays": [],
-      "content": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "durationSettings": {
-        "holiday": {
-          "calcType": 1,
-          "durationType": 1,
-          "holidayPlanOvertimeRedress": null,
-          "holidayPlanOvertimeRedressBy": null,
-          "holidayPlanVacationRate": null,
-          "overtimeRedress": true,
-          "overtimeRedressBy": "vacation",
-          "roundType": null,
-          "settings": {},
-          "skipTime": "none",
-          "skipTimeByDurations": [],
-          "skipTimeByFrames": [],
-          "stepType": null,
-          "stepValue": null,
-          "vacationRate": "1.0",
-          "warningSettings": [],
-          "workMinutesPerDay": null
-        },
-        "restDay": {
-          "calcType": 1,
-          "durationType": 1,
-          "holidayPlanOvertimeRedress": null,
-          "holidayPlanOvertimeRedressBy": null,
-          "holidayPlanVacationRate": null,
-          "overtimeRedress": true,
-          "overtimeRedressBy": "vacation",
-          "roundType": null,
-          "settings": {},
-          "skipTime": "none",
-          "skipTimeByDurations": [],
-          "skipTimeByFrames": [],
-          "stepType": null,
-          "stepValue": null,
-          "vacationRate": "1.0",
-          "warningSettings": [],
-          "workMinutesPerDay": null
-        },
-        "workDay": {
-          "calcType": 1,
-          "durationType": 1,
-          "holidayPlanOvertimeRedress": null,
-          "holidayPlanOvertimeRedressBy": null,
-          "holidayPlanVacationRate": null,
-          "overtimeRedress": true,
-          "overtimeRedressBy": "vacation",
-          "roundType": null,
-          "settings": {
-            "calcTypeBetweenDutyWorktime": null,
-            "durationTypeBetweenDutyWorktime": null,
-            "dutyWorktimeLieuTypeVacationCode": null,
-            "dutyWorktimeOvertimeRedress": null,
-            "dutyWorktimeOvertimeRedressBy": null,
-            "dutyWorktimeVacationRate": null,
-            "enableAfterOffDuty": true,
-            "enableBeforeOnDuty": true,
-            "enableBetweenDuty": true,
-            "enableBetweenDutyWorktime": null,
-            "startTime": null,
-            "timeUnit": null,
-            "timeUnitAfterOffDuty": 30,
-            "timeUnitBeforeOnDuty": 30,
-            "timeUnitBetweenDuty": 0,
-            "timeUnitBetweenDutyWorktime": null
-          },
-          "skipTime": "none",
-          "skipTimeByDurations": [],
-          "skipTimeByFrames": [],
-          "stepType": null,
-          "stepValue": null,
-          "vacationRate": "1.0",
-          "warningSettings": [],
-          "workMinutesPerDay": null
-        }
-      },
-      "groupIdAndNames": [],
-      "id": 941615052,
-      "isDefault": null,
-      "name": "以审批时间计算加班",
-      "overtimeCheckSetting": {},
-      "overtimeDivisions": [],
-      "owner": null,
-      "ownerList": [],
-      "roundType": "floor",
-      "scopes": null,
-      "settingId": 941615052,
-      "stepType": 0,
-      "stepValue": "1.0",
-      "warningSettings": [],
-      "workMinutesPerDay": 480
-    }
-  ],
-  "success": true
-}
dws attendance +get-schedule --users <users> --start <start> --end <end>read
获取指定用户一段时间内的排班记录
dws mcp attendance getScheduleByRange --json '{"GetScheduleByRangeRequest":<..>, "userIdList":<..>, "workDateBegin":<..>, "workDateEnd":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss
输入 / CLI 输出
输入
dws attendance +get-schedule --users x --start x --end x --yes
stdout
(空)
stderr / error
日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss
最终用于判定的返回
日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss
真实后端测试输入 / 输出真实后端成功
exit=0;duration=929ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-schedule --users 202397 --start 2026-07-14 --end 2026-07-15 --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
dws attendance +get-self-setting --setting-scene <setting-scene> --user <user>read
查询个人规则设置(打卡提醒/极速打卡/缺卡提醒等)
dws mcp attendance query_self_setting --json '{"RuleMcpQuerySelfSettingRequest":<..>, "settingScene":<..>, "userId":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/query_self_setting
输入 / CLI 输出
输入
dws attendance +get-self-setting --setting-scene checkRemind --user x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "RuleMcpQuerySelfSettingRequest": {
-      "settingScene": "checkRemind",
-      "userId": "x"
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "query_self_setting"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "RuleMcpQuerySelfSettingRequest": {
-      "settingScene": "checkRemind",
-      "userId": "x"
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "query_self_setting"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=989ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-self-setting --setting-scene checkRemind --user 202397 --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": {
-    "bossMonthReportType": null,
-    "bossPushStartMin": null,
-    "bossWeekReportType": null,
-    "canUpdateOffDuty": null,
-    "checkRemindSetting": {},
-    "checkRemindUserOffDuty": true,
-    "checkRemindUserOnDuty": true,
-    "checkResultMsg": null,
-    "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-    "enableOffdutyCheckRemindOfPc": true,
-    "enableOndutyCheckRemindOfPc": true,
-    "fastCheckLateNeedConfirm": null,
-    "lackRemindUser": null,
-    "lackSendTodoMsg": null,
-    "offdutyCheckType": null,
-    "offdutyRemindEndMin": null,
-    "offdutyRemindStartMin": null,
-    "ondutyCheckType": null,
-    "ondutyRemindEndMin": null,
-    "ondutyRemindStartMin": null,
-    "personDailyReportSwitch": null,
-    "personMonthReportType": null,
-    "personWeekReportType": null,
-    "userId": "202397",
-    "vibrationRemindSwitch": null,
-    "voiceRemindSwitch": null
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": {
-    "bossMonthReportType": null,
-    "bossPushStartMin": null,
-    "bossWeekReportType": null,
-    "canUpdateOffDuty": null,
-    "checkRemindSetting": {},
-    "checkRemindUserOffDuty": true,
-    "checkRemindUserOnDuty": true,
-    "checkResultMsg": null,
-    "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-    "enableOffdutyCheckRemindOfPc": true,
-    "enableOndutyCheckRemindOfPc": true,
-    "fastCheckLateNeedConfirm": null,
-    "lackRemindUser": null,
-    "lackSendTodoMsg": null,
-    "offdutyCheckType": null,
-    "offdutyRemindEndMin": null,
-    "offdutyRemindStartMin": null,
-    "ondutyCheckType": null,
-    "ondutyRemindEndMin": null,
-    "ondutyRemindStartMin": null,
-    "personDailyReportSwitch": null,
-    "personMonthReportType": null,
-    "personWeekReportType": null,
-    "userId": "202397",
-    "vibrationRemindSwitch": null,
-    "voiceRemindSwitch": null
-  },
-  "success": true
-}
dws attendance +get-summary --user <user> --date <date> --stats-type <stats-type>read
查询某个人的考勤统计摘要(周/月)
dws mcp attendance get_user_attendance_summary --json '{"userId":<..>, "queryDate":<..>, "statsType":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss
输入 / CLI 输出
输入
dws attendance +get-summary --user x --date x --stats-type week --yes
stdout
(空)
stderr / error
日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss
最终用于判定的返回
日期格式错误,应为 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1315ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +get-summary --user 202397 --date 2026-07-15 --stats-type week --yes --format json
真实 stdout
{
-  "errorCode": "0",
-  "errorMessage": "success",
-  "result": {
-    "items": [
-      {
-        "name": "平均工时",
-        "remark": "0小时",
-        "unit": "小时"
-      },
-      {
-        "name": "出勤天数",
-        "remark": "0天",
-        "unit": null
-      },
-      {
-        "name": "出勤班次",
-        "remark": "0个",
-        "unit": null
-      },
-      {
-        "name": "休息天数",
-        "remark": "0天",
-        "unit": null
-      },
-      {
-        "name": "迟到",
-        "remark": "0次",
-        "unit": "次"
-      },
-      {
-        "name": "早退",
-        "remark": "0次",
-        "unit": "次"
-      },
-      {
-        "name": "缺卡",
-        "remark": "0次",
-        "unit": "次"
-      },
-      {
-        "name": "旷工",
-        "remark": "0天",
-        "unit": null
-      },
-      {
-        "name": "外勤",
-        "remark": "0次",
-        "unit": null
-      },
-      {
-        "name": "加班",
-        "remark": "0小时",
-        "unit": null
-      },
-      {
-        "name": "补卡申请",
-        "remark": "0次",
-        "unit": null
-      }
-    ]
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "errorCode": "0",
-  "errorMessage": "success",
-  "result": {
-    "items": [
-      {
-        "name": "平均工时",
-        "remark": "0小时",
-        "unit": "小时"
-      },
-      {
-        "name": "出勤天数",
-        "remark": "0天",
-        "unit": null
-      },
-      {
-        "name": "出勤班次",
-        "remark": "0个",
-        "unit": null
-      },
-      {
-        "name": "休息天数",
-        "remark": "0天",
-        "unit": null
-      },
-      {
-        "name": "迟到",
-        "remark": "0次",
-        "unit": "次"
-      },
-      {
-        "name": "早退",
-        "remark": "0次",
-        "unit": "次"
-      },
-      {
-        "name": "缺卡",
-        "remark": "0次",
-        "unit": "次"
-      },
-      {
-        "name": "旷工",
-        "remark": "0天",
-        "unit": null
-      },
-      {
-        "name": "外勤",
-        "remark": "0次",
-        "unit": null
-      },
-      {
-        "name": "加班",
-        "remark": "0小时",
-        "unit": null
-      },
-      {
-        "name": "补卡申请",
-        "remark": "0次",
-        "unit": null
-      }
-    ]
-  },
-  "success": true
-}
dws attendance +import-schedule --group-id <group-id> --schedules <schedules>write
导入排班记录到排班制考勤组
dws mcp attendance generateTurnSchedule --json '{"GenerateTurnScheduleRequest":<..>, "groupId":<..>, "scheduleVOS":<..>, "param":<..>, "useHistoryGroupAndShift":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --schedules JSON: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws attendance +import-schedule --group-id 1 --schedules x --yes
stdout
(空)
stderr / error
invalid --schedules JSON: invalid character 'x' looking for beginning of value
最终用于判定的返回
invalid --schedules JSON: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1271ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +import-schedule --group-id 999999999999 --schedules '[{"userId":"202397","workDate":"2026-07-16 09:00:00","classId":999999999999,"isRest":"N"}]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0003",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840999034845189e085e"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0003",
-    "server_key": "attendance-wukong",
-    "trace_id": "2104a64c17840999034845189e085e"
-  }
-}
dws attendance +list-approve --users <users> --types <types> --start <start> --end <end>read
查询用户考勤审批单(补卡/加班/请假/出差外出)
dws mcp attendance query_user_approve --json '{"QueryUserApproveRequest":<..>, "userIds":<..>, "bizTypes":<..>, "fromDate":<..>, "toDate":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws attendance +list-approve --users x --types x --start x --end x --yes
stdout
(空)
stderr / error
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
最终用于判定的返回
日期格式错误,应为 YYYY-MM-DD(如 2026-04-01): parsing time "x" as "2006-01-02": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1208ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +list-approve --users 202397 --types leave --start 2026-07-14 --end 2026-07-15 --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
dws attendance +list-leave-typesread
查询当前用户可用的假期规则列表
dws mcp attendance get_leave_types --json '{"McpLeaveTypeRequest":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_leave_types
输入 / CLI 输出
输入
dws attendance +list-leave-types --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "McpLeaveTypeRequest": {}
-  },
-  "product": "attendance-wukong",
-  "tool": "get_leave_types"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "McpLeaveTypeRequest": {}
-  },
-  "product": "attendance-wukong",
-  "tool": "get_leave_types"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1107ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +list-leave-types --yes --format json
真实 stdout
{
-  "result": [
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "754f72b4-0476-4e7d-ae4c-c46486268c5c",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "事假",
-      "leaveStatisticType": "freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "a49f333e-275b-489a-8ec7-2c2000b81f35",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "调休",
-      "leaveStatisticType": "balance_cycle_freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "5c4380ea-6f9c-4955-bbe6-bc0d114e4676",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "病假",
-      "leaveStatisticType": "freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "731ed089-62ff-4734-a6c7-3c8fcc8294fc",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "年假",
-      "leaveStatisticType": "balance_cycle",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "halfDay",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "8a551c65-d3e1-4671-9eaf-78dd4645b8c3",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "产假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "904d3f51-1f73-4161-a50a-300d0009676d",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "陪产假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "05e2ba99-1916-4be9-8166-54f6a652e3bc",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "婚假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "b168cb41-fbca-4f28-8234-fc62e0b66a2b",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "例假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "halfDay",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "9daf653b-b3dd-46ef-a2b0-dd160bbf08c4",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "丧假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "3730df36-c05c-45e7-bac1-26b2ba137a25",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "哺乳假",
-      "leaveStatisticType": "freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "result": [
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "754f72b4-0476-4e7d-ae4c-c46486268c5c",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "事假",
-      "leaveStatisticType": "freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "a49f333e-275b-489a-8ec7-2c2000b81f35",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "调休",
-      "leaveStatisticType": "balance_cycle_freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "5c4380ea-6f9c-4955-bbe6-bc0d114e4676",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "病假",
-      "leaveStatisticType": "freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "731ed089-62ff-4734-a6c7-3c8fcc8294fc",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "年假",
-      "leaveStatisticType": "balance_cycle",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "halfDay",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "8a551c65-d3e1-4671-9eaf-78dd4645b8c3",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "产假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "904d3f51-1f73-4161-a50a-300d0009676d",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "陪产假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "05e2ba99-1916-4be9-8166-54f6a652e3bc",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "婚假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "b168cb41-fbca-4f28-8234-fc62e0b66a2b",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "例假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "halfDay",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "9daf653b-b3dd-46ef-a2b0-dd160bbf08c4",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "丧假",
-      "leaveStatisticType": "once_max",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_day",
-      "leaveViewUnit": "day",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    },
-    {
-      "allRecordCleanTime": null,
-      "bizType": null,
-      "corpId": "ding8196cd9a2b2405da24f2f5cc6abecb85",
-      "exsitFlag": "y",
-      "gmtCreate": 1650879712000,
-      "gmtModified": 1650879712000,
-      "leaveBalanceRuleVO": {},
-      "leaveCertificate": {},
-      "leaveCode": "3730df36-c05c-45e7-bac1-26b2ba137a25",
-      "leaveCycleStatisticInfoVO": {},
-      "leaveHourCeil": null,
-      "leaveName": "哺乳假",
-      "leaveStatisticType": "freedom",
-      "leaveStatus": "on",
-      "leaveTimeCeil": null,
-      "leaveTimeCeilMinUnit": null,
-      "leaveUnit": "percent_hour",
-      "leaveViewUnit": "hour",
-      "maxLeaveTime": null,
-      "minLeaveHour": null,
-      "naturalDayLeave": false,
-      "orderId": null,
-      "paidLeave": false,
-      "perHoursInDay": 0,
-      "remindRuleVO": {},
-      "source": "inner",
-      "submitTimeRuleVO": {},
-      "sysFlag": "n",
-      "userId": null,
-      "version": "1",
-      "visibilityRules": [],
-      "whenCanLeave": "entry"
-    }
-  ],
-  "success": true
-}
dws attendance +list-report-columnsread
获取企业考勤报表字段列表(仅管理员)
dws mcp attendance get_report_columns无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_report_columns
输入 / CLI 输出
输入
dws attendance +list-report-columns --yes
stdout
{
-  "_fake": true,
-  "args": {},
-  "product": "attendance-wukong",
-  "tool": "get_report_columns"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {},
-  "product": "attendance-wukong",
-  "tool": "get_report_columns"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1351ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test attendance +list-report-columns --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "attendance-wukong",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"6001\",\"errorMsg\":\"无权限操作\"} ",
-    "trace_id": "2127d89817840997666188472e0756"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "attendance-wukong",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"6001\",\"errorMsg\":\"无权限操作\"} ",
-    "trace_id": "2127d89817840997666188472e0756"
-  }
-}
dws attendance +my-attendanceread复合/智能型
查我今天的考勤打卡记录(打卡流水,自动解析当前用户)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_current_user_profile;没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。
输入 / CLI 输出
输入
dws attendance +my-attendance --yes
stdout
(空)
stderr / error
没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。
最终用于判定的返回
没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1169ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +my-attendance --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
dws attendance +query-report-data --users <users> --columns <columns> --start <start> --end <end>read
根据字段查询考勤报表数据(仅管理员)
dws mcp attendance get_report_columns_value --json '{"McpQueryParam":<..>, "targetUserIds":<..>, "columnIds":<..>, "fromDate":<..>, "toDate":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws attendance +query-report-data --users x --columns x --start x --end x --yes
stdout
(空)
stderr / error
--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
最终用于判定的返回
--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=998ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +query-report-data --users 202397 --columns 1001 --start '2026-07-15 10:00:00' --end '2026-07-15 16:00:00' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": [],
-  "success": true
-}
dws attendance +query-report-leave --users <users> --leave-names <leave-names> --start <start> --end <end>read
查询用户假期数据(仅管理员)
dws mcp attendance get_leave_time_by_leave_names --json '{"McpLeaveQueryParam":<..>, "targetUserIds":<..>, "leaveNames":<..>, "fromDate":<..>, "toDate":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws attendance +query-report-leave --users x --leave-names x --start x --end x --yes
stdout
(空)
stderr / error
--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
最终用于判定的返回
--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: parsing time "x" as "2006-01-02 15:04:05": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1373ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test attendance +query-report-leave --users 202397 --leave-names '年假' --start '2026-07-15 10:00:00' --end '2026-07-15 16:00:00' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "attendance-wukong",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"6001\",\"errorMsg\":\"无权限操作\"} ",
-    "trace_id": "2104a64c17840997679973065e085f"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "attendance-wukong",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"6001\",\"errorMsg\":\"无权限操作\"} ",
-    "trace_id": "2104a64c17840997679973065e085f"
-  }
-}
dws attendance +save-leave-balance --target <target> --leave-code <leave-code> --num <num> --reason <reason> --start <start> --end <end>write
设置员工假期余额(SET 覆盖,非累加)
dws mcp attendance update_leave_balance --json '{"targetUserId":<..>, "leaveCode":<..>, "quotaNum":<..>, "reason":<..>, "McpUpdateLeaveBalanceRequest":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--num 格式错误: strconv.ParseFloat: parsing "x": invalid syntax(应为数字,如 8 或 7.5)
输入 / CLI 输出
输入
dws attendance +save-leave-balance --target x --leave-code x --num x --reason x --start x --end x --yes
stdout
(空)
stderr / error
--num 格式错误: strconv.ParseFloat: parsing "x": invalid syntax(应为数字,如 8 或 7.5)
最终用于判定的返回
--num 格式错误: strconv.ParseFloat: parsing "x": invalid syntax(应为数字,如 8 或 7.5)
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1081ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test attendance +save-leave-balance --target __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --leave-code DWSREALTESTNOSUCHID0000000000000 --num 1 --reason 'DWS shortcut 真实测试安全负向清理' --start 2026-07-16 --end 2026-08-14 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PERMISSION_DENIED",
-    "server_key": "attendance-wukong",
-    "trace_id": "2127d89817840999045751134e079c"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PERMISSION_DENIED",
-    "server_key": "attendance-wukong",
-    "trace_id": "2127d89817840999045751134e079c"
-  }
-}
dws attendance +search-adjustment-rule --query <query> --page <page> --limit <limit>read
查询当前用户可管理的补卡规则列表
dws mcp attendance <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_adjustment_rule
输入 / CLI 输出
输入
dws attendance +search-adjustment-rule --query x --page 1 --limit 1 --yes
stdout
{
-  "count": 0,
-  "rules": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "rules": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=985ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +search-adjustment-rule --query '测试' --page 1 --limit 10 --yes --format json
真实 stdout
{
-  "count": 0,
-  "rules": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "rules": []
-}
dws attendance +search-class --query <query> --filter-type <filter-type> --page <page> --limit <limit>read
查询当前用户可管理的班次详情列表
dws mcp attendance <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_class_list
输入 / CLI 输出
输入
dws attendance +search-class --query x --filter-type ALL --page 1 --limit 1 --yes
stdout
{
-  "classes": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "classes": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1043ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +search-class --query '测试' --filter-type ALL --page 1 --limit 10 --yes --format json
真实 stdout
{
-  "classes": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "classes": [],
-  "count": 0
-}
dws attendance +search-group --query <query> --type <type> --query-position <query-position> --query-ble <query-ble> --page <page> --limit <limit>read
查询当前用户可管理的考勤组列表
dws mcp attendance <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_simple_groups
输入 / CLI 输出
输入
dws attendance +search-group --query x --type FIXED --query-position --query-ble --page 1 --limit 1 --yes
stdout
{
-  "count": 0,
-  "groups": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "groups": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=939ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +search-group --query '测试' --type FIXED --query-position --query-ble --page 1 --limit 10 --yes --format json
真实 stdout
{
-  "count": 0,
-  "groups": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "groups": []
-}
dws attendance +search-overtime-rule --query <query> --page <page> --limit <limit>read
查询当前用户可管理的加班规则列表
dws mcp attendance <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/get_overtime_rule
输入 / CLI 输出
输入
dws attendance +search-overtime-rule --query x --page 1 --limit 1 --yes
stdout
{
-  "count": 0,
-  "rules": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "rules": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=997ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +search-overtime-rule --query '测试' --page 1 --limit 10 --yes --format json
真实 stdout
{
-  "count": 0,
-  "rules": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "rules": []
-}
dws attendance +this-monthread复合/智能型
查我本月的考勤打卡记录(打卡流水,自动解析当前用户)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_current_user_profile;没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。
输入 / CLI 输出
输入
dws attendance +this-month --yes
stdout
(空)
stderr / error
没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。
最终用于判定的返回
没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1108ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test attendance +this-month --yes --format json
真实 stdout
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "code": "0",
-  "message": "success",
-  "result": [],
-  "success": true
-}
dws attendance +update-class --class-id <class-id> --name <name> --owner <owner> --class-vo <class-vo>write
更新已有班次(仅传要修改的字段)
dws mcp attendance update_class_setting --json '{"TopAtClassVO":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --class-vo JSON: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws attendance +update-class --class-id 1 --name x --owner x --class-vo x --yes
stdout
(空)
stderr / error
invalid --class-vo JSON: invalid character 'x' looking for beginning of value
最终用于判定的返回
invalid --class-vo JSON: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1248ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +update-class --class-id 999999999999 --name 'DWS shortcut 真实测试 20260715-151724' --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --class-vo '{"sections":[{"times":[{"checkType":"OnDuty","checkTime":"09:00","across":0},{"checkType":"OffDuty","checkTime":"18:00","across":0}]}]}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "0b5deb3217840999058236471e08b5"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "0b5deb3217840999058236471e08b5"
-  }
-}
dws attendance +update-group --group-id <group-id> --name <name> --type <type> --owner <owner> --enable-outside-check <enable-outside-check> --class-ids <class-ids> --group-vo <group-vo>write
更新考勤组配置(仅修改需要变更的字段)
dws mcp attendance update_group_setting --json '{"groupId":<..>, "groupVO":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --group-vo JSON: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws attendance +update-group --group-id 1 --name x --type FIXED --owner x --enable-outside-check true --class-ids x --group-vo x --yes
stdout
(空)
stderr / error
invalid --group-vo JSON: invalid character 'x' looking for beginning of value
最终用于判定的返回
invalid --group-vo JSON: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1175ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +update-group --group-id 999999999999 --name 'DWS shortcut 真实测试 20260715-151724' --type FIXED --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --enable-outside-check true --class-ids '[999999999999]' --group-vo '{"defaultClassId":999999999999,"workDayClassList":[999999999999,999999999999,999999999999,999999999999,999999999999,0,0]}' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "0b5deb3217840999068826691e087a"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0001",
-    "server_key": "attendance-wukong",
-    "trace_id": "0b5deb3217840999068826691e087a"
-  }
-}
dws attendance +update-group-members --group-id <group-id> --add-users <add-users> --remove-users <remove-users> --add-extra-users <add-extra-users> --remove-extra-users <remove-extra-users> --add-depts <add-depts> --remove-depts <remove-depts>write
更新考勤组成员(增删考勤人员/部门/无需考勤人员)
dws mcp attendance update_group_member --json '{"groupId":<..>, "updateParam":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;attendance-wukong/update_group_member
输入 / CLI 输出
输入
dws attendance +update-group-members --group-id 1 --add-users x --remove-users x --add-extra-users x --remove-extra-users x --add-depts x --remove-depts x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "groupId": 1,
-    "updateParam": {
-      "addDepts": [
-        "x"
-      ],
-      "addExtraUsers": [
-        "x"
-      ],
-      "addUsers": [
-        "x"
-      ],
-      "removeDepts": [
-        "x"
-      ],
-      "removeExtraUsers": [
-        "x"
-      ],
-      "removeUsers": [
-        "x"
-      ]
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "update_group_member"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "groupId": 1,
-    "updateParam": {
-      "addDepts": [
-        "x"
-      ],
-      "addExtraUsers": [
-        "x"
-      ],
-      "addUsers": [
-        "x"
-      ],
-      "removeDepts": [
-        "x"
-      ],
-      "removeExtraUsers": [
-        "x"
-      ],
-      "removeUsers": [
-        "x"
-      ]
-    }
-  },
-  "product": "attendance-wukong",
-  "tool": "update_group_member"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1170ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test attendance +update-group-members --group-id 999999999999 --add-users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --remove-users '' --add-extra-users '' --remove-extra-users '' --add-depts '' --remove-depts '' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0002",
-    "server_key": "attendance-wukong",
-    "trace_id": "2127d89817840999081094644e07dd"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "S0002",
-    "server_key": "attendance-wukong",
-    "trace_id": "2127d89817840999081094644e07dd"
-  }
-}
dws attendance +update-leave-type --leave-code <leave-code> --name <name> --unit <unit> --paid <paid> --per-hours <per-hours> --when-can-leave <when-can-leave> --visibility-rules <visibility-rules>write
更新已有假期规则(仅传要修改的字段)
dws mcp attendance save_leave_type --json '{"leaveCode":<..>, "McpSaveLeaveTypeRequest":<..>}'无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --visibility-rules JSON: invalid character 'x' looking for beginning of value
输入 / CLI 输出
输入
dws attendance +update-leave-type --leave-code x --name x --unit day --paid --per-hours 1 --when-can-leave entry --visibility-rules x --yes
stdout
(空)
stderr / error
invalid --visibility-rules JSON: invalid character 'x' looking for beginning of value
最终用于判定的返回
invalid --visibility-rules JSON: invalid character 'x' looking for beginning of value
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1208ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test attendance +update-leave-type --leave-code DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --unit day --paid --per-hours 1 --when-can-leave entry --visibility-rules '[{"type":"dept","visible":["-1"]}]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "INVALID_PARAMS",
-    "server_key": "attendance-wukong",
-    "trace_id": "0bb7c36217840999093924897e07fe"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "INVALID_PARAMS",
-    "server_key": "attendance-wukong",
-    "trace_id": "0bb7c36217840999093924897e07fe"
-  }
-}
-

devapp · 30 个 · ↔ lark apps

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws devapp +create --name <name> --desc <desc> --icon-media-id <icon-media-id>write
创建开放平台企业内部应用
dws mcp devapp create_dev_app --json '{"name":<..>, "desc":<..>, "iconMediaId":<..>}'lark-cli apps +create已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/create_dev_app
输入 / CLI 输出
输入
dws devapp +create --name x --desc x --icon-media-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "desc": "x",
-    "iconMediaId": "x",
-    "name": "x"
-  },
-  "product": "devapp",
-  "tool": "create_dev_app"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "desc": "x",
-    "iconMediaId": "x",
-    "name": "x"
-  },
-  "product": "devapp",
-  "tool": "create_dev_app"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1464ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +create --name 'DWS测试应用0715' --desc 'DWS shortcut 真实测试,可删除' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "appKey": "dingyqsdchocxp3xrfm3",
-    "desc": "DWS shortcut 真实测试,可删除",
-    "icon": "@lADPDfmVQafoVxrMyMzI",
-    "iconUrl": "https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg",
-    "name": "DWS测试应用0715",
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "appKey": "dingyqsdchocxp3xrfm3",
-    "desc": "DWS shortcut 真实测试,可删除",
-    "icon": "@lADPDfmVQafoVxrMyMzI",
-    "iconUrl": "https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg",
-    "name": "DWS测试应用0715",
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
dws devapp +credentials-get --unified-app-id <unified-app-id>read
读取开放平台应用凭证
dws mcp devapp get_dev_app_credentials --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/get_dev_app_credentials
输入 / CLI 输出
输入
dws devapp +credentials-get --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app_credentials"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app_credentials"
-}
真实后端测试输入 / 输出待真实执行
exit=None;duration=0ms;held; sensitive credential read
失败归因 / 可修复性
人工授权后执行 / 需人工授权
-高风险或无安全目标,需人工逐项授权后执行。
真实输入
真实 stdout
(空)
真实 stderr / error
该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。
真实返回
该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。
dws devapp +delete --unified-app-id <unified-app-id>high-risk-write
删除开放平台企业内部应用(不可逆)
dws mcp devapp delete_dev_app --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/delete_dev_app
输入 / CLI 输出
输入
dws devapp +delete --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "delete_dev_app"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "delete_dev_app"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2868ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +delete --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "deleted": true,
-    "message": "应用删除成功。",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "deleted": true,
-    "message": "应用删除成功。",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
dws devapp +disable --unified-app-id <unified-app-id>high-risk-write
停用开放平台企业内部应用
dws mcp devapp disable_dev_app --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/disable_dev_app
输入 / CLI 输出
输入
dws devapp +disable --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "disable_dev_app"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "disable_dev_app"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2583ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +disable --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "message": "应用已停用。",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "message": "应用已停用。",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
dws devapp +enable --unified-app-id <unified-app-id>write
启用开放平台企业内部应用
dws mcp devapp enable_dev_app --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/enable_dev_app
输入 / CLI 输出
输入
dws devapp +enable --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "enable_dev_app"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "enable_dev_app"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1506ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +enable --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "message": "应用已启用。",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "message": "应用已启用。",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
dws devapp +event-list --unified-app-id <unified-app-id> --keyword <keyword>read
查询应用已订阅的事件列表
dws mcp devapp <tool>lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/list_dev_app_events
输入 / CLI 输出
输入
dws devapp +event-list --unified-app-id x --keyword x --cursor x --page-size 1 --yes
stdout
{
-  "count": 0,
-  "events": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "events": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1214ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +event-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --keyword '测试' --cursor '' --page-size 10 --yes --format json
真实 stdout
{
-  "count": 0,
-  "events": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "events": []
-}
dws devapp +event-subscribe --unified-app-id <unified-app-id> --event-codes <event-codes>write
订阅应用事件回调
dws mcp devapp subscribe_dev_app_events --json '{"unifiedAppId":<..>, "eventCodes":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/subscribe_dev_app_events
输入 / CLI 输出
输入
dws devapp +event-subscribe --unified-app-id x --event-codes x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "eventCodes": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "subscribe_dev_app_events"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "eventCodes": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "subscribe_dev_app_events"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1034ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +event-subscribe --unified-app-id DWSREALTESTNOSUCHID0000000000000 --event-codes '[]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2104a64c17840999564022020e08c7"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2104a64c17840999564022020e08c7"
-  }
-}
dws devapp +event-unsubscribe --unified-app-id <unified-app-id> --event-codes <event-codes>high-risk-write
取消订阅应用事件
dws mcp devapp unsubscribe_dev_app_events --json '{"unifiedAppId":<..>, "eventCodes":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/unsubscribe_dev_app_events
输入 / CLI 输出
输入
dws devapp +event-unsubscribe --unified-app-id x --event-codes x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "eventCodes": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "unsubscribe_dev_app_events"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "eventCodes": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "unsubscribe_dev_app_events"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1134ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +event-unsubscribe --unified-app-id DWSREALTESTNOSUCHID0000000000000 --event-codes '[]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用事件订阅权限\"} ",
-    "trace_id": "0bb7c36217840999575698500e07db"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用事件订阅权限\"} ",
-    "trace_id": "0bb7c36217840999575698500e07db"
-  }
-}
dws devapp +get --unified-app-id <unified-app-id>read
查询开放平台企业内部应用详情
dws mcp devapp get_dev_app --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/get_dev_app
输入 / CLI 输出
输入
dws devapp +get --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1268ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "appKey": "dingdtqbtxgtn2r5uyge",
-    "appStatus": "normal",
-    "canDelete": true,
-    "canUpdate": true,
-    "creator": "董鑫阳",
-    "desc": "DTDennisLobster",
-    "gmtCreate": "2026-03-06 10:41:40",
-    "gmtModified": "2026-03-06 10:41:40",
-    "icon": "@lADPDfmVQafoVxrMyMzI",
-    "iconUrl": "https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg",
-    "name": "DTDennisLobster",
-    "supportHarmony": false,
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "appKey": "dingdtqbtxgtn2r5uyge",
-    "appStatus": "normal",
-    "canDelete": true,
-    "canUpdate": true,
-    "creator": "董鑫阳",
-    "desc": "DTDennisLobster",
-    "gmtCreate": "2026-03-06 10:41:40",
-    "gmtModified": "2026-03-06 10:41:40",
-    "icon": "@lADPDfmVQafoVxrMyMzI",
-    "iconUrl": "https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg",
-    "name": "DTDennisLobster",
-    "supportHarmony": false,
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
dws devapp +list --name <name> --app-key <app-key> --app-group-id <app-group-id> --creator <creator> --robot-name <robot-name> --develop-type <develop-type> --filter-cool-app <filter-cool-app> --sort-type <sort-type> --sort-order <sort-order>read
查询开放平台企业内部应用列表
dws mcp devapp <tool>lark-cli apps +list已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/list_dev_app
输入 / CLI 输出
输入
dws devapp +list --name x --app-key x --app-group-id 1 --creator x --robot-name x --develop-type 1 --filter-cool-app 1 --sort-type x --sort-order x --cursor x --page-size 1 --yes
stdout
{
-  "apps": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "apps": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=8524ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +list --name 'DWS shortcut 真实测试' --app-key x --app-group-id 1 --creator x --robot-name x --develop-type 1 --filter-cool-app 1 --sort-type x --sort-order x --cursor '' --page-size 10 --yes --format json
真实 stdout
{
-  "apps": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "apps": [],
-  "count": 0
-}
dws devapp +member-add --unified-app-id <unified-app-id> --user-ids <user-ids> --member-type <member-type>write
添加开放平台应用成员
dws mcp devapp add_dev_app_members --json '{"unifiedAppId":<..>, "userIds":<..>, "memberType":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/add_dev_app_members
输入 / CLI 输出
输入
dws devapp +member-add --unified-app-id x --user-ids x --member-type x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "memberType": "x",
-    "unifiedAppId": "x",
-    "userIds": [
-      "x"
-    ]
-  },
-  "product": "devapp",
-  "tool": "add_dev_app_members"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "memberType": "x",
-    "unifiedAppId": "x",
-    "userIds": [
-      "x"
-    ]
-  },
-  "product": "devapp",
-  "tool": "add_dev_app_members"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2393ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +member-add --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --user-ids 103262 --member-type DEVELOPER --yes --format json
真实 stdout
{
-  "memberType": "DEVELOPER",
-  "operation": "ADDED",
-  "success": true,
-  "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692",
-  "userIds": [
-    "103262"
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "memberType": "DEVELOPER",
-  "operation": "ADDED",
-  "success": true,
-  "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692",
-  "userIds": [
-    "103262"
-  ]
-}
dws devapp +member-list --unified-app-id <unified-app-id>read
查询开放平台应用成员
dws mcp devapp list_dev_app_members --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/list_dev_app_members
输入 / CLI 输出
输入
dws devapp +member-list --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "list_dev_app_members"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "list_dev_app_members"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1404ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +member-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json
真实 stdout
{
-  "members": [
-    {
-      "memberType": "OWNER",
-      "name": "董鑫阳",
-      "userId": "202397"
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "members": [
-    {
-      "memberType": "OWNER",
-      "name": "董鑫阳",
-      "userId": "202397"
-    }
-  ],
-  "success": true
-}
dws devapp +member-remove --unified-app-id <unified-app-id> --user-ids <user-ids> --member-type <member-type>high-risk-write
移除开放平台应用成员
dws mcp devapp remove_dev_app_members --json '{"unifiedAppId":<..>, "userIds":<..>, "memberType":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/remove_dev_app_members
输入 / CLI 输出
输入
dws devapp +member-remove --unified-app-id x --user-ids x --member-type x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "memberType": "x",
-    "unifiedAppId": "x",
-    "userIds": [
-      "x"
-    ]
-  },
-  "product": "devapp",
-  "tool": "remove_dev_app_members"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "memberType": "x",
-    "unifiedAppId": "x",
-    "userIds": [
-      "x"
-    ]
-  },
-  "product": "devapp",
-  "tool": "remove_dev_app_members"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1188ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +member-remove --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --user-ids 103262 --member-type DEVELOPER --yes --format json
真实 stdout
{
-  "memberType": "DEVELOPER",
-  "operation": "REMOVED",
-  "success": true,
-  "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692",
-  "userIds": [
-    "103262"
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "memberType": "DEVELOPER",
-  "operation": "REMOVED",
-  "success": true,
-  "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692",
-  "userIds": [
-    "103262"
-  ]
-}
dws devapp +permission-add --unified-app-id <unified-app-id> --scope-values <scope-values>write
申请开放平台应用权限点
dws mcp devapp apply_dev_app_permissions --json '{"unifiedAppId":<..>, "scopeValues":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/apply_dev_app_permissions
输入 / CLI 输出
输入
dws devapp +permission-add --unified-app-id x --scope-values x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "scopeValues": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "apply_dev_app_permissions"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "scopeValues": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "apply_dev_app_permissions"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1186ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +permission-add --unified-app-id DWSREALTESTNOSUCHID0000000000000 --scope-values '[]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2127d89817840999587758457e079c"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2127d89817840999587758457e079c"
-  }
-}
dws devapp +permission-list --unified-app-id <unified-app-id> --keyword <keyword> --scope-value <scope-value> --auth-status <auth-status> --scope-type <scope-type> --api-status <api-status>read
查询开放平台应用权限列表
dws mcp devapp <tool>lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/list_dev_app_permissions
输入 / CLI 输出
输入
dws devapp +permission-list --unified-app-id x --keyword x --scope-value x --auth-status x --scope-type x --api-status x --cursor x --page-size 1 --yes
stdout
{
-  "count": 0,
-  "permissions": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "permissions": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1578ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +permission-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --keyword '测试' --auth-status ALL --cursor '' --page-size 10 --yes --format json
真实 stdout
{
-  "count": 0,
-  "permissions": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "permissions": []
-}
dws devapp +permission-remove --unified-app-id <unified-app-id> --scope-values <scope-values>high-risk-write
取消开放平台应用权限点
dws mcp devapp remove_dev_app_permissions --json '{"unifiedAppId":<..>, "scopeValues":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/remove_dev_app_permissions
输入 / CLI 输出
输入
dws devapp +permission-remove --unified-app-id x --scope-values x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "scopeValues": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "remove_dev_app_permissions"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "scopeValues": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "remove_dev_app_permissions"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1061ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +permission-remove --unified-app-id DWSREALTESTNOSUCHID0000000000000 --scope-values '[]' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2104a64c17840999598445247e085e"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2104a64c17840999598445247e085e"
-  }
-}
dws devapp +robot-config --unified-app-id <unified-app-id> --name <name> --brief <brief> --desc <desc> --icon-media-id <icon-media-id> --outgoing-url <outgoing-url> --event-callback-url <event-callback-url> --mode <mode> --skills <skills> --add-scope <add-scope> --disable-ssl-verify <disable-ssl-verify>write
创建或更新现有应用的机器人配置(upsert)
dws mcp devapp set_extension_robot_config --json '{"unifiedAppId":<..>, "name":<..>, "brief":<..>, "desc":<..>, "iconMediaId":<..>, "outgoingUrl":<..>, "eventCallbackUrl":<..>, "mode":<..>, "skills":<..>, "addScope":<..>, "disableSSLVerify":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/set_extension_robot_config
输入 / CLI 输出
输入
dws devapp +robot-config --unified-app-id x --name x --brief x --desc x --icon-media-id x --outgoing-url x --event-callback-url x --mode HTTPS --skills x --add-scope --disable-ssl-verify --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "addScope": true,
-    "brief": "x",
-    "desc": "x",
-    "disableSSLVerify": true,
-    "eventCallbackUrl": "x",
-    "iconMediaId": "x",
-    "mode": "HTTPS",
-    "name": "x",
-    "outgoingUrl": "x",
-    "skills": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "set_extension_robot_config"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "addScope": true,
-    "brief": "x",
-    "desc": "x",
-    "disableSSLVerify": true,
-    "eventCallbackUrl": "x",
-    "iconMediaId": "x",
-    "mode": "HTTPS",
-    "name": "x",
-    "outgoingUrl": "x",
-    "skills": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "set_extension_robot_config"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1167ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +robot-config --unified-app-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --brief x --desc 'DWS shortcut 真实测试描述,可删除' --icon-media-id DWSREALTESTNOSUCHID0000000000000 --outgoing-url https://example.invalid/dws-shortcut-real-test --event-callback-url https://example.invalid/dws-shortcut-real-test --mode HTTPS --skills '[]' --add-scope --disable-ssl-verify --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用机器人配置权限\"} ",
-    "trace_id": "0bb7c36217840999609828961e07db"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用机器人配置权限\"} ",
-    "trace_id": "0bb7c36217840999609828961e07db"
-  }
-}
dws devapp +robot-disable --unified-app-id <unified-app-id>high-risk-write
停用现有应用的机器人能力
dws mcp devapp disable_dev_app_robot --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/disable_dev_app_robot
输入 / CLI 输出
输入
dws devapp +robot-disable --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "disable_dev_app_robot"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "disable_dev_app_robot"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1062ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +robot-disable --unified-app-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用机器人配置权限\"} ",
-    "trace_id": "2104a64c17840999620461113e08ee"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用机器人配置权限\"} ",
-    "trace_id": "2104a64c17840999620461113e08ee"
-  }
-}
dws devapp +robot-enable --unified-app-id <unified-app-id>write
启用现有应用机器人能力(纯启用,无需配置字段)
dws mcp devapp enable_dev_app_robot --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/enable_dev_app_robot
输入 / CLI 输出
输入
dws devapp +robot-enable --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "enable_dev_app_robot"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "enable_dev_app_robot"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1306ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +robot-enable --unified-app-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用机器人配置权限\"} ",
-    "trace_id": "0bb7c36217840999632641525e0758"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "AUTH_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"503\",\"errorMsg\":\"当前用户没有应用机器人配置权限\"} ",
-    "trace_id": "0bb7c36217840999632641525e0758"
-  }
-}
dws devapp +robot-get --unified-app-id <unified-app-id>read
查询现有应用的机器人配置
dws mcp devapp get_extension_robot_config --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/get_extension_robot_config
输入 / CLI 输出
输入
dws devapp +robot-get --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_extension_robot_config"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_extension_robot_config"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1204ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +robot-get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "configured": false,
-    "message": "应用未配置机器人。",
-    "robotStatus": "UNCONFIGURED",
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "configured": false,
-    "message": "应用未配置机器人。",
-    "robotStatus": "UNCONFIGURED",
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552"
-  },
-  "success": true
-}
dws devapp +security-config --unified-app-id <unified-app-id> --ip-whitelist <ip-whitelist> --redirect-urls <redirect-urls> --sso-urls <sso-urls>write
更新开放平台应用安全配置(整组覆盖,非追加)
dws mcp devapp update_dev_app_security_config --json '{"unifiedAppId":<..>, "ipWhitelist":<..>, "redirectUrls":<..>, "ssoUrls":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/update_dev_app_security_config
输入 / CLI 输出
输入
dws devapp +security-config --unified-app-id x --ip-whitelist x --redirect-urls x --sso-urls x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "ipWhitelist": [
-      "x"
-    ],
-    "redirectUrls": [
-      "x"
-    ],
-    "ssoUrls": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "update_dev_app_security_config"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "ipWhitelist": [
-      "x"
-    ],
-    "redirectUrls": [
-      "x"
-    ],
-    "ssoUrls": [
-      "x"
-    ],
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "update_dev_app_security_config"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1205ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test devapp +security-config --unified-app-id DWSREALTESTNOSUCHID0000000000000 --ip-whitelist 127.0.0.1 --redirect-urls https://example.invalid/dws-shortcut-real-test --sso-urls https://example.invalid/dws-shortcut-real-test --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2104a64c17840999645522046e0817"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "503",
-    "server_key": "devapp",
-    "trace_id": "2104a64c17840999645522046e0817"
-  }
-}
dws devapp +update --unified-app-id <unified-app-id> --name <name> --desc <desc> --icon-media-id <icon-media-id>write
修改开放平台企业内部应用基础信息
dws mcp devapp update_dev_app --json '{"unifiedAppId":<..>, "name":<..>, "desc":<..>, "iconMediaId":<..>}'lark-cli apps +update已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/update_dev_app
输入 / CLI 输出
输入
dws devapp +update --unified-app-id x --name x --desc x --icon-media-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "desc": "x",
-    "iconMediaId": "x",
-    "name": "x",
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "update_dev_app"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "desc": "x",
-    "iconMediaId": "x",
-    "name": "x",
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "update_dev_app"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=5611ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +update --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --name 'DWS测试应用0715改' --desc 'DWS shortcut 真实测试已更新' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "appKey": "dingyqsdchocxp3xrfm3",
-    "desc": "DWS shortcut 真实测试已更新",
-    "icon": "@lADPDfmVQafoVxrMyMzI",
-    "iconUrl": "https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg",
-    "message": "应用基础信息更新成功。",
-    "name": "DWS测试应用0715改",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692",
-    "updatedFields": [
-      "name",
-      "desc"
-    ],
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "appKey": "dingyqsdchocxp3xrfm3",
-    "desc": "DWS shortcut 真实测试已更新",
-    "icon": "@lADPDfmVQafoVxrMyMzI",
-    "iconUrl": "https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg",
-    "message": "应用基础信息更新成功。",
-    "name": "DWS测试应用0715改",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692",
-    "updatedFields": [
-      "name",
-      "desc"
-    ],
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
dws devapp +version-check-approval --unified-app-id <unified-app-id> --version-id <version-id>read
预检版本发布是否需要审批(不实际发布)
dws mcp devapp publish_dev_app_version --json '{"unifiedAppId":<..>, "versionId":<..>, "precheckOnly":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/publish_dev_app_version
输入 / CLI 输出
输入
dws devapp +version-check-approval --unified-app-id x --version-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "precheckOnly": true,
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "publish_dev_app_version"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "precheckOnly": true,
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "publish_dev_app_version"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2081ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +version-check-approval --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --version-id 4743accb-45e2-4bc9-8c96-74fc62ace2e8 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "approvalCandidates": [
-      {
-        "mainAdmin": false,
-        "name": "王军",
-        "userId": "084896"
-      },
-      {
-        "mainAdmin": false,
-        "name": "尤坤",
-        "userId": "425982"
-      },
-      {
-        "mainAdmin": false,
-        "name": "贺汝坤",
-        "userId": "453209"
-      },
-      {
-        "mainAdmin": false,
-        "name": "倪爽",
-        "userId": "443841"
-      },
-      {
-        "mainAdmin": false,
-        "name": "谢传贵",
-        "userId": "014823"
-      },
-      {
-        "mainAdmin": false,
-        "name": "史伟民",
-        "userId": "188288"
-      },
-      {
-        "mainAdmin": false,
-        "name": "郭迪",
-        "userId": "452337"
-      },
-      {
-        "mainAdmin": false,
-        "name": "周子杰",
-        "userId": "312753"
-      },
-      {
-        "mainAdmin": false,
-        "name": "刘畅",
-        "userId": "068373"
-      },
-      {
-        "mainAdmin": false,
-        "name": "张磊",
-        "userId": "371811"
-      },
-      {
-        "mainAdmin": false,
-        "name": "霍俊龙",
-        "userId": "068428"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李晓军",
-        "userId": "004897"
-      },
-      {
-        "mainAdmin": false,
-        "name": "姚海龙",
-        "userId": "107578"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王丽丽",
-        "userId": "246929"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李恺杰",
-        "userId": "272720"
-      },
-      {
-        "mainAdmin": false,
-        "name": "夏东翔",
-        "userId": "103262"
-      },
-      {
-        "mainAdmin": false,
-        "name": "朱鸿",
-        "userId": "024083"
-      },
-      {
-        "mainAdmin": false,
-        "name": "叶冬",
-        "userId": "371817"
-      },
-      {
-        "mainAdmin": false,
-        "name": "任卿",
-        "userId": "006792"
-      },
-      {
-        "mainAdmin": false,
-        "name": "彭家俊",
-        "userId": "225893"
-      },
-      {
-        "mainAdmin": false,
-        "name": "刘蓓蓓",
-        "userId": "074268"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王耀波",
-        "userId": "220629"
-      },
-      {
-        "mainAdmin": false,
-        "name": "夏烈阳",
-        "userId": "176371"
-      },
-      {
-        "mainAdmin": false,
-        "name": "魏玉科",
-        "userId": "061331"
-      },
-      {
-        "mainAdmin": false,
-        "name": "张晨阳",
-        "userId": "244717"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李祎嵩",
-        "userId": "238863"
-      },
-      {
-        "mainAdmin": false,
-        "name": "吕思霖",
-        "userId": "193021"
-      },
-      {
-        "mainAdmin": false,
-        "name": "宋宜涛",
-        "userId": "037168"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李红军",
-        "userId": "133206"
-      },
-      {
-        "mainAdmin": false,
-        "name": "沈运亮",
-        "userId": "101595"
-      },
-      {
-        "mainAdmin": false,
-        "name": "桂明月",
-        "userId": "077725"
-      },
-      {
-        "mainAdmin": false,
-        "name": "吴芳祥",
-        "userId": "074360"
-      },
-      {
-        "mainAdmin": false,
-        "name": "赵召航",
-        "userId": "044473"
-      },
-      {
-        "mainAdmin": false,
-        "name": "黄文奇",
-        "userId": "270175"
-      },
-      {
-        "mainAdmin": false,
-        "name": "董金威",
-        "userId": "085729"
-      },
-      {
-        "mainAdmin": false,
-        "name": "孙博文",
-        "userId": "034766"
-      },
-      {
-        "mainAdmin": false,
-        "name": "朱通",
-        "userId": "327979"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王帅军",
-        "userId": "061731"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王海旭",
-        "userId": "273048"
-      },
-      {
-        "mainAdmin": false,
-        "name": "欧阳德仁",
-        "userId": "167272"
-      },
-      {
-        "mainAdmin": false,
-        "name": "冯汝伟",
-        "userId": "124456"
-      },
-      {
-        "mainAdmin": false,
-        "name": "刘啸天",
-        "userId": "314418"
-      },
-      {
-        "mainAdmin": false,
-        "name": "杨期",
-        "userId": "028611"
-      },
-      {
-        "mainAdmin": false,
-        "name": "宋华青",
-        "userId": "054469"
-      },
-      {
-        "mainAdmin": false,
-        "name": "俞福春",
-        "userId": "424920"
-      },
-      {
-        "mainAdmin": false,
-        "name": "汤素文",
-        "userId": "088045"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王摧",
-        "userId": "141842"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王鸣霏",
-        "userId": "184163"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王兵",
-        "userId": "135976"
-      },
-      {
-        "mainAdmin": false,
-        "name": "周剑云",
-        "userId": "164217"
-      },
-      {
-        "mainAdmin": false,
-        "name": "沈国勋",
-        "userId": "026247"
-      },
-      {
-        "mainAdmin": false,
-        "name": "高阿娜",
-        "userId": "001423"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王森",
-        "userId": "045981"
-      },
-      {
-        "mainAdmin": false,
-        "name": "林朋",
-        "userId": "236891"
-      }
-    ],
-    "approvalMode": "SELECT_APPROVER",
-    "approvalSubmitted": false,
-    "publishable": false,
-    "published": false,
-    "requiresApproval": true,
-    "sensitive": false,
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "version": "1.0.0",
-    "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "approvalCandidates": [
-      {
-        "mainAdmin": false,
-        "name": "王军",
-        "userId": "084896"
-      },
-      {
-        "mainAdmin": false,
-        "name": "尤坤",
-        "userId": "425982"
-      },
-      {
-        "mainAdmin": false,
-        "name": "贺汝坤",
-        "userId": "453209"
-      },
-      {
-        "mainAdmin": false,
-        "name": "倪爽",
-        "userId": "443841"
-      },
-      {
-        "mainAdmin": false,
-        "name": "谢传贵",
-        "userId": "014823"
-      },
-      {
-        "mainAdmin": false,
-        "name": "史伟民",
-        "userId": "188288"
-      },
-      {
-        "mainAdmin": false,
-        "name": "郭迪",
-        "userId": "452337"
-      },
-      {
-        "mainAdmin": false,
-        "name": "周子杰",
-        "userId": "312753"
-      },
-      {
-        "mainAdmin": false,
-        "name": "刘畅",
-        "userId": "068373"
-      },
-      {
-        "mainAdmin": false,
-        "name": "张磊",
-        "userId": "371811"
-      },
-      {
-        "mainAdmin": false,
-        "name": "霍俊龙",
-        "userId": "068428"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李晓军",
-        "userId": "004897"
-      },
-      {
-        "mainAdmin": false,
-        "name": "姚海龙",
-        "userId": "107578"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王丽丽",
-        "userId": "246929"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李恺杰",
-        "userId": "272720"
-      },
-      {
-        "mainAdmin": false,
-        "name": "夏东翔",
-        "userId": "103262"
-      },
-      {
-        "mainAdmin": false,
-        "name": "朱鸿",
-        "userId": "024083"
-      },
-      {
-        "mainAdmin": false,
-        "name": "叶冬",
-        "userId": "371817"
-      },
-      {
-        "mainAdmin": false,
-        "name": "任卿",
-        "userId": "006792"
-      },
-      {
-        "mainAdmin": false,
-        "name": "彭家俊",
-        "userId": "225893"
-      },
-      {
-        "mainAdmin": false,
-        "name": "刘蓓蓓",
-        "userId": "074268"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王耀波",
-        "userId": "220629"
-      },
-      {
-        "mainAdmin": false,
-        "name": "夏烈阳",
-        "userId": "176371"
-      },
-      {
-        "mainAdmin": false,
-        "name": "魏玉科",
-        "userId": "061331"
-      },
-      {
-        "mainAdmin": false,
-        "name": "张晨阳",
-        "userId": "244717"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李祎嵩",
-        "userId": "238863"
-      },
-      {
-        "mainAdmin": false,
-        "name": "吕思霖",
-        "userId": "193021"
-      },
-      {
-        "mainAdmin": false,
-        "name": "宋宜涛",
-        "userId": "037168"
-      },
-      {
-        "mainAdmin": false,
-        "name": "李红军",
-        "userId": "133206"
-      },
-      {
-        "mainAdmin": false,
-        "name": "沈运亮",
-        "userId": "101595"
-      },
-      {
-        "mainAdmin": false,
-        "name": "桂明月",
-        "userId": "077725"
-      },
-      {
-        "mainAdmin": false,
-        "name": "吴芳祥",
-        "userId": "074360"
-      },
-      {
-        "mainAdmin": false,
-        "name": "赵召航",
-        "userId": "044473"
-      },
-      {
-        "mainAdmin": false,
-        "name": "黄文奇",
-        "userId": "270175"
-      },
-      {
-        "mainAdmin": false,
-        "name": "董金威",
-        "userId": "085729"
-      },
-      {
-        "mainAdmin": false,
-        "name": "孙博文",
-        "userId": "034766"
-      },
-      {
-        "mainAdmin": false,
-        "name": "朱通",
-        "userId": "327979"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王帅军",
-        "userId": "061731"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王海旭",
-        "userId": "273048"
-      },
-      {
-        "mainAdmin": false,
-        "name": "欧阳德仁",
-        "userId": "167272"
-      },
-      {
-        "mainAdmin": false,
-        "name": "冯汝伟",
-        "userId": "124456"
-      },
-      {
-        "mainAdmin": false,
-        "name": "刘啸天",
-        "userId": "314418"
-      },
-      {
-        "mainAdmin": false,
-        "name": "杨期",
-        "userId": "028611"
-      },
-      {
-        "mainAdmin": false,
-        "name": "宋华青",
-        "userId": "054469"
-      },
-      {
-        "mainAdmin": false,
-        "name": "俞福春",
-        "userId": "424920"
-      },
-      {
-        "mainAdmin": false,
-        "name": "汤素文",
-        "userId": "088045"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王摧",
-        "userId": "141842"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王鸣霏",
-        "userId": "184163"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王兵",
-        "userId": "135976"
-      },
-      {
-        "mainAdmin": false,
-        "name": "周剑云",
-        "userId": "164217"
-      },
-      {
-        "mainAdmin": false,
-        "name": "沈国勋",
-        "userId": "026247"
-      },
-      {
-        "mainAdmin": false,
-        "name": "高阿娜",
-        "userId": "001423"
-      },
-      {
-        "mainAdmin": false,
-        "name": "王森",
-        "userId": "045981"
-      },
-      {
-        "mainAdmin": false,
-        "name": "林朋",
-        "userId": "236891"
-      }
-    ],
-    "approvalMode": "SELECT_APPROVER",
-    "approvalSubmitted": false,
-    "publishable": false,
-    "published": false,
-    "requiresApproval": true,
-    "sensitive": false,
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "version": "1.0.0",
-    "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
dws devapp +version-create --unified-app-id <unified-app-id> --version <version> --desc <desc>write
基于当前配置创建应用新版本
dws mcp devapp create_dev_app_version --json '{"unifiedAppId":<..>, "version":<..>, "desc":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/create_dev_app_version
输入 / CLI 输出
输入
dws devapp +version-create --unified-app-id x --version x --desc x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "desc": "x",
-    "unifiedAppId": "x",
-    "version": "x"
-  },
-  "product": "devapp",
-  "tool": "create_dev_app_version"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "desc": "x",
-    "unifiedAppId": "x",
-    "version": "x"
-  },
-  "product": "devapp",
-  "tool": "create_dev_app_version"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1157ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test devapp +version-create --unified-app-id DWSREALTESTNOSUCHID0000000000000 --version 0.0.1-dws-real-test --desc 'DWS shortcut 真实测试描述,可删除' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "BUSINESS_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"450001\"} ",
-    "trace_id": "0b5deb3217840999656705920e0853"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "BUSINESS_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"450001\"} ",
-    "trace_id": "0b5deb3217840999656705920e0853"
-  }
-}
dws devapp +version-get --unified-app-id <unified-app-id> --version-id <version-id>read
查询指定版本详情
dws mcp devapp get_dev_app_version_detail --json '{"unifiedAppId":<..>, "versionId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/get_dev_app_version_detail
输入 / CLI 输出
输入
dws devapp +version-get --unified-app-id x --version-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app_version_detail"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app_version_detail"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1738ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +version-get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --version-id 4743accb-45e2-4bc9-8c96-74fc62ace2e8 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "abilityList": [],
-    "approvalMode": "SELECT_APPROVER",
-    "description": "1.0.0",
-    "enterpriseSelfBuiltAudit": false,
-    "gmtCreate": "2026-03-06 10:43:01",
-    "requiresApproval": true,
-    "scopeList": [
-      "qyapi_robot_sendmsg",
-      "Card.Instance.Write",
-      "Card.Streaming.Write"
-    ],
-    "sensitiveScopeList": [],
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "version": "1.0.0",
-    "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "abilityList": [],
-    "approvalMode": "SELECT_APPROVER",
-    "description": "1.0.0",
-    "enterpriseSelfBuiltAudit": false,
-    "gmtCreate": "2026-03-06 10:43:01",
-    "requiresApproval": true,
-    "scopeList": [
-      "qyapi_robot_sendmsg",
-      "Card.Instance.Write",
-      "Card.Streaming.Write"
-    ],
-    "sensitiveScopeList": [],
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "version": "1.0.0",
-    "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
dws devapp +version-list --unified-app-id <unified-app-id>read
分页查询应用版本列表
dws mcp devapp <tool>lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/list_dev_app_versions
输入 / CLI 输出
输入
dws devapp +version-list --unified-app-id x --cursor x --page-size 1 --yes
stdout
{
-  "count": 0,
-  "versions": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "versions": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1349ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +version-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --cursor '' --page-size 10 --yes --format json
真实 stdout
{
-  "count": 1,
-  "versions": [
-    {
-      "desc": "1.0.0",
-      "gmtCreate": 1772764981000,
-      "status": "INIT",
-      "version": "1.0.0",
-      "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 1,
-  "versions": [
-    {
-      "desc": "1.0.0",
-      "gmtCreate": 1772764981000,
-      "status": "INIT",
-      "version": "1.0.0",
-      "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8"
-    }
-  ]
-}
dws devapp +version-publish --unified-app-id <unified-app-id> --version-id <version-id> --confirmed-sensitive <confirmed-sensitive> --approver-user-id <approver-user-id>write
发布指定版本(含高敏权限需 --confirmed-sensitive)
dws mcp devapp publish_dev_app_version --json '{"unifiedAppId":<..>, "versionId":<..>, "precheckOnly":<..>, "confirmedSensitive":<..>, "approverUserId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/publish_dev_app_version
输入 / CLI 输出
输入
dws devapp +version-publish --unified-app-id x --version-id x --confirmed-sensitive --approver-user-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "approverUserId": "x",
-    "confirmedSensitive": true,
-    "precheckOnly": false,
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "publish_dev_app_version"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "approverUserId": "x",
-    "confirmedSensitive": true,
-    "precheckOnly": false,
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "publish_dev_app_version"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1071ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test devapp +version-publish --unified-app-id DWSREALTESTNOSUCHID0000000000000 --version-id DWSREALTESTNOSUCHID0000000000000 --confirmed-sensitive --approver-user-id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "BUSINESS_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"450001\"} ",
-    "trace_id": "2127d89817840999667906017e075d"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "BUSINESS_ERROR",
-    "server_key": "devapp",
-    "technical_detail": "iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\"success\":false,\"errorCode\":\"450001\"} ",
-    "trace_id": "2127d89817840999667906017e075d"
-  }
-}
dws devapp +version-status --unified-app-id <unified-app-id> --version-id <version-id>read
查询版本发布/审批状态
dws mcp devapp get_dev_app_version_status --json '{"unifiedAppId":<..>, "versionId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/get_dev_app_version_status
输入 / CLI 输出
输入
dws devapp +version-status --unified-app-id x --version-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app_version_status"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x",
-    "versionId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_dev_app_version_status"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2272ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +version-status --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --version-id 4743accb-45e2-4bc9-8c96-74fc62ace2e8 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "version": "1.0.0",
-    "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552",
-    "version": "1.0.0",
-    "versionId": "4743accb-45e2-4bc9-8c96-74fc62ace2e8",
-    "versionStatus": "INIT"
-  },
-  "success": true
-}
dws devapp +webapp-config --unified-app-id <unified-app-id> --h5-page-type <h5-page-type> --homepage-url <homepage-url> --pc-homepage-url <pc-homepage-url> --omp-url <omp-url>write
配置网页应用能力
dws mcp devapp set_extension_webapp_config --json '{"unifiedAppId":<..>, "h5PageType":<..>, "homepageUrl":<..>, "pcHomepageUrl":<..>, "ompUrl":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/set_extension_webapp_config
输入 / CLI 输出
输入
dws devapp +webapp-config --unified-app-id x --h5-page-type x --homepage-url x --pc-homepage-url x --omp-url x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "h5PageType": "x",
-    "homepageUrl": "x",
-    "ompUrl": "x",
-    "pcHomepageUrl": "x",
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "set_extension_webapp_config"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "h5PageType": "x",
-    "homepageUrl": "x",
-    "ompUrl": "x",
-    "pcHomepageUrl": "x",
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "set_extension_webapp_config"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=4926ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +webapp-config --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --h5-page-type pc --homepage-url https://example.invalid/dws --pc-homepage-url https://example.invalid/dws-pc --omp-url https://example.invalid/omp --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "configured": true,
-    "h5PageType": "pc",
-    "homepageUrl": "https://example.invalid/dws",
-    "message": "网页应用配置成功。",
-    "ompUrl": "https://example.invalid/omp",
-    "pcHomepageUrl": "https://example.invalid/dws-pc",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "configured": true,
-    "h5PageType": "pc",
-    "homepageUrl": "https://example.invalid/dws",
-    "message": "网页应用配置成功。",
-    "ompUrl": "https://example.invalid/omp",
-    "pcHomepageUrl": "https://example.invalid/dws-pc",
-    "success": true,
-    "unifiedAppId": "c5a8e611-1555-44a0-ba8d-9617ab599692"
-  },
-  "success": true
-}
dws devapp +webapp-get --unified-app-id <unified-app-id>read
查询网页应用配置
dws mcp devapp get_extension_webapp_config --json '{"unifiedAppId":<..>}'lark apps 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;devapp/get_extension_webapp_config
输入 / CLI 输出
输入
dws devapp +webapp-get --unified-app-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_extension_webapp_config"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "unifiedAppId": "x"
-  },
-  "product": "devapp",
-  "tool": "get_extension_webapp_config"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1576ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test devapp +webapp-get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "configured": false,
-    "message": "未配置网页应用入口,请使用 webapp config 设置 homepageUrl、pcHomepageUrl 或 ompUrl。",
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "configured": false,
-    "message": "未配置网页应用入口,请使用 webapp config 设置 homepageUrl、pcHomepageUrl 或 ompUrl。",
-    "unifiedAppId": "678f27ec-4339-49d8-9c49-371b284bf552"
-  },
-  "success": true
-}
-

calendar · 23 个 · ↔ lark calendar

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws calendar +agenda --start <start> --end <end> --calendar-id <calendar-id> --cursor <cursor> --limit <limit>read
查询日程列表(不传时间默认查询今天)
dws mcp calendar <tool>lark-cli calendar +agenda实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
输入 / CLI 输出
输入
dws calendar +agenda --start x --end x --calendar-id x --cursor x --limit 1 --yes
stdout
(空)
stderr / error
invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
最终用于判定的返回
invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2094ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +agenda --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --calendar-id primary --cursor '' --limit 10 --yes --format json
真实 stdout
{
-  "count": 1,
-  "events": [
-    {
-      "end": {
-        "date": null,
-        "dateTime": "2026-07-16T10:00:00+08:00",
-        "timeZone": "Asia/Shanghai"
-      },
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000",
-      "location": "B310",
-      "start": {
-        "date": null,
-        "dateTime": "2026-07-16T09:15:00+08:00",
-        "timeZone": "Asia/Shanghai"
-      },
-      "status": "confirmed",
-      "summary": "中控专属钉&AI项目早会"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 1,
-  "events": [
-    {
-      "end": {
-        "date": null,
-        "dateTime": "2026-07-16T10:00:00+08:00",
-        "timeZone": "Asia/Shanghai"
-      },
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000",
-      "location": "B310",
-      "start": {
-        "date": null,
-        "dateTime": "2026-07-16T09:15:00+08:00",
-        "timeZone": "Asia/Shanghai"
-      },
-      "status": "confirmed",
-      "summary": "中控专属钉&AI项目早会"
-    }
-  ]
-}
dws calendar +attendee-list --event <event> --calendar-id <calendar-id>read
查看日程参会人
dws mcp calendar <tool>lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/get_calendar_participants
输入 / CLI 输出
输入
dws calendar +attendee-list --event x --calendar-id x --yes
stdout
{
-  "attendees": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "attendees": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1144ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +attendee-list --event THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000 --calendar-id primary --yes --format json
真实 stdout
{
-  "attendees": [
-    {
-      "displayName": "秦天",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "崇幻(主用钉)",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "恩澈主用钉",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "楠者",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "栩铭-主账号",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "思敏",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "念晨",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "杨淑平",
-      "responseStatus": "declined"
-    },
-    {
-      "displayName": "李伟韬",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "王琳",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "千启",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "粤腾",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "石涤",
-      "responseStatus": "declined"
-    },
-    {
-      "displayName": "陈叙",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "李畅(主用钉)",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "徐海儿",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "袁航",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王伟明",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "巫利华",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "张彪",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "舒畅",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王晓璇",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "孙茂林",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "尤福海",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨凯",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨二磊",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "姚俊杰",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "王鑫杰",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "毛金权",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王国红",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "刘刚",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王福超",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "张海蛟",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "方胜新",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "邵嘉铭",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "孙龙飞",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "刘琦",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "申展",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "李光辉",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "蒋志恒",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "籍敏",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "黄徐贤",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王继光",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "裴美娟",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "闫铎功",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "陈江义",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "刘颖",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨旭[工作号]",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "刘少军",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王贵",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "付玉强",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "张振奎",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "武永江",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "沈体峰",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "孔维娜",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王东亮",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "李鑫",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "张磊",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "朱晓闪",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "吴斌",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "陈露鑫",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "陈永纯",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "李所清",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "盛超",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王军超",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "曲宁涛",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "方美",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨帆",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "万以华",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "毕晨",
-      "responseStatus": "accepted"
-    }
-  ],
-  "count": 70
-}
真实 stderr / error
(空)
真实返回
{
-  "attendees": [
-    {
-      "displayName": "秦天",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "崇幻(主用钉)",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "恩澈主用钉",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "楠者",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "栩铭-主账号",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "思敏",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "念晨",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "杨淑平",
-      "responseStatus": "declined"
-    },
-    {
-      "displayName": "李伟韬",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "王琳",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "千启",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "粤腾",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "石涤",
-      "responseStatus": "declined"
-    },
-    {
-      "displayName": "陈叙",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "李畅(主用钉)",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "徐海儿",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "袁航",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王伟明",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "巫利华",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "张彪",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "舒畅",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王晓璇",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "孙茂林",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "尤福海",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨凯",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨二磊",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "姚俊杰",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "王鑫杰",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "毛金权",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王国红",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "刘刚",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王福超",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "张海蛟",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "方胜新",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "邵嘉铭",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "孙龙飞",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "刘琦",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "申展",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "李光辉",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "蒋志恒",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "籍敏",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "黄徐贤",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王继光",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "裴美娟",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "闫铎功",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "陈江义",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "刘颖",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨旭[工作号]",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "刘少军",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王贵",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "付玉强",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "张振奎",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "武永江",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "沈体峰",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "孔维娜",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王东亮",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "李鑫",
-      "responseStatus": "accepted"
-    },
-    {
-      "displayName": "张磊",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "朱晓闪",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "吴斌",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "陈露鑫",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "陈永纯",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "李所清",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "盛超",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "王军超",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "曲宁涛",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "方美",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "杨帆",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "万以华",
-      "responseStatus": "needsAction"
-    },
-    {
-      "displayName": "毕晨",
-      "responseStatus": "accepted"
-    }
-  ],
-  "count": 70
-}
dws calendar +book --title <title> --start <start> --end <end> --with <with>write复合/智能型
创建日程,并可按姓名邀请参会人(自动解析 userId,失败自动回滚删除日程)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 create_calendar_eventlark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws calendar +book --title x --start x --end x --with x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2909ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +book --title 'DWS shortcut 真实测试 20260715 日程,可忽略' --start 2026-07-16T11:00:00+08:00 --end 2026-07-16T11:15:00+08:00 --with '冬翔,克谨,怒龙' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "result": {
-    "attendees": [
-      {
-        "displayName": "怒龙",
-        "optional": false,
-        "responseStatus": "needsAction",
-        "self": false
-      },
-      {
-        "displayName": "冬翔",
-        "optional": false,
-        "responseStatus": "needsAction",
-        "self": false
-      },
-      {
-        "displayName": "念晨",
-        "optional": false,
-        "responseStatus": "accepted",
-        "self": true
-      },
-      {
-        "displayName": "黄渊卓(克谨)",
-        "optional": false,
-        "responseStatus": "needsAction",
-        "self": false
-      }
-    ],
-    "categories": [],
-    "createTime": "2026-07-15 10:12:30",
-    "end": {
-      "dateTime": "2026-07-16T11:15:00+08:00",
-      "timeZone": "Asia/Shanghai"
-    },
-    "extendedProperties": {
-      "sharedProperties": {
-        "belongCorpId": "ding8196cd9a2b2405da24f2f5cc6abecb85"
-      }
-    },
-    "freeBusy": "BUSY",
-    "iCalUID": "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09",
-    "id": "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09",
-    "isAllDay": false,
-    "meetingRooms": [],
-    "onlineMeetingInfo": {
-      "conferenceId": "c76abd52-29bb-4183-85d3-bcf91debe3e4",
-      "extraInfo": {
-        "phones": "[\"+862381045152\",\"+862381045153\"]",
-        "roomCode": "131 508 161"
-      },
-      "internalExtraInfo": {
-        "urlForCalDAV": "dingtalk://dingtalkclient/page/videoConfFromSystemCalendar?confId=c76abd52-29bb-4183-85d3-bcf91debe3e4\u0026calendarId=24714119393"
-      },
-      "type": "dingtalk",
-      "url": "dingtalk://dingtalkclient/page/videoConfFromCalendar?confId=c76abd52-29bb-4183-85d3-bcf91debe3e4\u0026calendarId=24714119393"
-    },
-    "organizer": {
-      "displayName": "念晨",
-      "optional": false,
-      "self": true
-    },
-    "reminders": [
-      {
-        "method": "dingtalk",
-        "minutes": 15
-      }
-    ],
-    "start": {
-      "dateTime": "2026-07-16T11:00:00+08:00",
-      "timeZone": "Asia/Shanghai"
-    },
-    "status": "confirmed",
-    "summary": "DWS shortcut 真实测试 20260715 日程,可忽略",
-    "updateTime": "2026-07-15 10:12:31"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "result": {
-    "attendees": [
-      {
-        "displayName": "怒龙",
-        "optional": false,
-        "responseStatus": "needsAction",
-        "self": false
-      },
-      {
-        "displayName": "冬翔",
-        "optional": false,
-        "responseStatus": "needsAction",
-        "self": false
-      },
-      {
-        "displayName": "念晨",
-        "optional": false,
-        "responseStatus": "accepted",
-        "self": true
-      },
-      {
-        "displayName": "黄渊卓(克谨)",
-        "optional": false,
-        "responseStatus": "needsAction",
-        "self": false
-      }
-    ],
-    "categories": [],
-    "createTime": "2026-07-15 10:12:30",
-    "end": {
-      "dateTime": "2026-07-16T11:15:00+08:00",
-      "timeZone": "Asia/Shanghai"
-    },
-    "extendedProperties": {
-      "sharedProperties": {
-        "belongCorpId": "ding8196cd9a2b2405da24f2f5cc6abecb85"
-      }
-    },
-    "freeBusy": "BUSY",
-    "iCalUID": "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09",
-    "id": "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09",
-    "isAllDay": false,
-    "meetingRooms": [],
-    "onlineMeetingInfo": {
-      "conferenceId": "c76abd52-29bb-4183-85d3-bcf91debe3e4",
-      "extraInfo": {
-        "phones": "[\"+862381045152\",\"+862381045153\"]",
-        "roomCode": "131 508 161"
-      },
-      "internalExtraInfo": {
-        "urlForCalDAV": "dingtalk://dingtalkclient/page/videoConfFromSystemCalendar?confId=c76abd52-29bb-4183-85d3-bcf91debe3e4\u0026calendarId=24714119393"
-      },
-      "type": "dingtalk",
-      "url": "dingtalk://dingtalkclient/page/videoConfFromCalendar?confId=c76abd52-29bb-4183-85d3-bcf91debe3e4\u0026calendarId=24714119393"
-    },
-    "organizer": {
-      "displayName": "念晨",
-      "optional": false,
-      "self": true
-    },
-    "reminders": [
-      {
-        "method": "dingtalk",
-        "minutes": 15
-      }
-    ],
-    "start": {
-      "dateTime": "2026-07-16T11:00:00+08:00",
-      "timeZone": "Asia/Shanghai"
-    },
-    "status": "confirmed",
-    "summary": "DWS shortcut 真实测试 20260715 日程,可忽略",
-    "updateTime": "2026-07-15 10:12:31"
-  },
-  "success": true
-}
dws calendar +book-listread
查询用户的日历本列表
dws mcp calendar <tool>lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/list_calendars
输入 / CLI 输出
输入
dws calendar +book-list --yes
stdout
{
-  "calendars": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "calendars": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1033ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +book-list --yes --format json
真实 stdout
{
-  "calendars": [
-    {
-      "calendarId": "primary",
-      "description": null,
-      "privilege": "owner",
-      "summary": "我的日历(钉钉)",
-      "type": "primary"
-    }
-  ],
-  "count": 1
-}
真实 stderr / error
(空)
真实返回
{
-  "calendars": [
-    {
-      "calendarId": "primary",
-      "description": null,
-      "privilege": "owner",
-      "summary": "我的日历(钉钉)",
-      "type": "primary"
-    }
-  ],
-  "count": 1
-}
dws calendar +book-search --query <query>read
按名称模糊搜索日历本
dws mcp calendar <tool>lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/search_calendar
输入 / CLI 输出
输入
dws calendar +book-search --query x --yes
stdout
{
-  "calendars": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "calendars": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1036ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +book-search --query '测试' --yes --format json
真实 stdout
{
-  "calendars": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "calendars": [],
-  "count": 0
-}
dws calendar +cancel-event --event <event>high-risk-write复合/智能型
取消(删除)一个已有日程(删除前先确认它真实存在)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 delete_calendar_eventlark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 2;tool 已校验;calendar/delete_calendar_event、calendar/get_calendar_detail
输入 / CLI 输出
输入
dws calendar +cancel-event --event x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "eventId": "x"
-  },
-  "product": "calendar",
-  "tool": "delete_calendar_event"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "eventId": "x"
-  },
-  "product": "calendar",
-  "tool": "delete_calendar_event"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1369ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +cancel-event --event c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09 --yes --format json
真实 stdout
{
-  "action": "about_to_cancel",
-  "eventId": "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09",
-  "title": "DWS shortcut 真实测试 20260715 日程,可忽略"
-}
-{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "action": "about_to_cancel",
-  "eventId": "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09",
-  "title": "DWS shortcut 真实测试 20260715 日程,可忽略"
-}
-{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws calendar +conflicts --in-days <in-days>read复合/智能型
检测我某天日程的时间冲突(重叠/双重预订,默认今天)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/list_calendar_events
输入 / CLI 输出
输入
dws calendar +conflicts --in-days 1 --yes
stdout
{
-  "conflictCount": 0,
-  "conflicts": [],
-  "date": "2026-07-16",
-  "eventCount": 0,
-  "hasConflict": false
-}
stderr / error
(空)
最终用于判定的返回
{
-  "conflictCount": 0,
-  "conflicts": [],
-  "date": "2026-07-16",
-  "eventCount": 0,
-  "hasConflict": false
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2369ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +conflicts --in-days 1 --yes --format json
真实 stdout
{
-  "conflictCount": 0,
-  "conflicts": [],
-  "date": "2026-07-16",
-  "eventCount": 3,
-  "hasConflict": false
-}
真实 stderr / error
(空)
真实返回
{
-  "conflictCount": 0,
-  "conflicts": [],
-  "date": "2026-07-16",
-  "eventCount": 3,
-  "hasConflict": false
-}
dws calendar +find-room --start <start> --end <end>read复合/智能型
查询指定时间段内所有可用的会议室
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--start 时间格式无效:"x",请使用 ISO8601(如 2026-03-10T14:00:00+08:00)
输入 / CLI 输出
输入
dws calendar +find-room --start x --end x --yes
stdout
(空)
stderr / error
--start 时间格式无效:"x",请使用 ISO8601(如 2026-03-10T14:00:00+08:00)
最终用于判定的返回
--start 时间格式无效:"x",请使用 ISO8601(如 2026-03-10T14:00:00+08:00)
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1498ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test calendar +find-room --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "458019",
-    "server_key": "calendar",
-    "trace_id": "0bb7c36217840997694975303e0758"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "458019",
-    "server_key": "calendar",
-    "trace_id": "0bb7c36217840997694975303e0758"
-  }
-}
dws calendar +free --who <who> --start <start> --end <end>read复合/智能型
按姓名查询某人在指定时间段内的忙闲状态(自动解析 userId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws calendar +free --who x --start x --end x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1697ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +free --who '冬翔' --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json
真实 stdout
{
-  "busy": [],
-  "free": true,
-  "userId": "103262",
-  "who": "夏东翔"
-}
真实 stderr / error
(空)
真实返回
{
-  "busy": [],
-  "free": true,
-  "userId": "103262",
-  "who": "夏东翔"
-}
dws calendar +free-slots --in-days <in-days> --from <from> --to <to>read复合/智能型
找我某天工作时段内的空闲时间段(默认今天 09:00-18:00)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--from/--to 需满足 0<=from<to<=24
输入 / CLI 输出
输入
dws calendar +free-slots --in-days 1 --from 1 --to 1 --yes
stdout
(空)
stderr / error
--from/--to 需满足 0<=from<to<=24
最终用于判定的返回
--from/--to 需满足 0<=from<to<=24
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2195ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +free-slots --in-days 1 --from 9 --to 18 --yes --format json
真实 stdout
{
-  "allBusy": false,
-  "date": "2026-07-16",
-  "freeSlots": [
-    {
-      "durationMin": 15,
-      "end": "09:15",
-      "start": "09:00"
-    },
-    {
-      "durationMin": 480,
-      "end": "18:00",
-      "start": "10:00"
-    }
-  ],
-  "slotCount": 2,
-  "window": "09:00-18:00"
-}
真实 stderr / error
(空)
真实返回
{
-  "allBusy": false,
-  "date": "2026-07-16",
-  "freeSlots": [
-    {
-      "durationMin": 15,
-      "end": "09:15",
-      "start": "09:00"
-    },
-    {
-      "durationMin": 480,
-      "end": "18:00",
-      "start": "10:00"
-    }
-  ],
-  "slotCount": 2,
-  "window": "09:00-18:00"
-}
dws calendar +freebusy --users <users> --rooms <rooms> --start <start> --end <end>read
查询用户 / 会议室闲忙状态(--users 与 --rooms 至少其一)
dws mcp calendar query_busy_status --json '{"startTime":<..>, "endTime":<..>, "userIds":<..>, "roomIds":<..>}'lark-cli calendar +freebusy实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
输入 / CLI 输出
输入
dws calendar +freebusy --users x --rooms x --start x --end x --yes
stdout
(空)
stderr / error
invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
最终用于判定的返回
invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1154ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +freebusy --users 103262 --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json
真实 stdout
{
-  "result": [
-    {
-      "error": null,
-      "roomId": null,
-      "scheduleItems": [],
-      "userId": "103262"
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "result": [
-    {
-      "error": null,
-      "roomId": null,
-      "scheduleItems": [],
-      "userId": "103262"
-    }
-  ],
-  "success": true
-}
dws calendar +invite --event <event> --with <with>write复合/智能型
按姓名把参会人加入已有日程(自动解析 userId 后批量添加)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 add_calendar_participantlark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws calendar +invite --event x --with x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2033ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +invite --event c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09 --with '冬翔' --yes --format json
真实 stdout
{
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws calendar +my-free --start <start> --end <end>read复合/智能型
查我自己在某时间段的忙闲(默认今天,无需输入姓名)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_current_user_profile;无法解析当前用户的 userId
输入 / CLI 输出
输入
dws calendar +my-free --start x --end x --yes
stdout
(空)
stderr / error
无法解析当前用户的 userId
最终用于判定的返回
无法解析当前用户的 userId
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1406ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +my-free --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json
真实 stdout
{
-  "busy": [
-    {
-      "end": "2026-07-16T10:00:00+08:00",
-      "start": "2026-07-16T09:15:00+08:00"
-    }
-  ],
-  "free": false,
-  "userId": "202397"
-}
真实 stderr / error
(空)
真实返回
{
-  "busy": [
-    {
-      "end": "2026-07-16T10:00:00+08:00",
-      "start": "2026-07-16T09:15:00+08:00"
-    }
-  ],
-  "free": false,
-  "userId": "202397"
-}
dws calendar +next-eventread复合/智能型
查看接下来最近的一个日程(默认扫描未来 7 天)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/list_calendar_events
输入 / CLI 输出
输入
dws calendar +next-event --yes
stdout
{
-  "event": null,
-  "message": "近 7 天无日程"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "event": null,
-  "message": "近 7 天无日程"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=5504ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +next-event --yes --format json
真实 stdout
{
-  "event": {
-    "end": "2026-07-15 21:15",
-    "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000",
-    "start": "2026-07-15 21:00",
-    "title": "循环提醒:项目每日工作进展同步"
-  }
-}
真实 stderr / error
(空)
真实返回
{
-  "event": {
-    "end": "2026-07-15 21:15",
-    "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000",
-    "start": "2026-07-15 21:00",
-    "title": "循环提醒:项目每日工作进展同步"
-  }
-}
dws calendar +reschedule --event <event> --start <start> --end <end>write复合/智能型
改一个已有日程的时间(只动开始/结束时间,其他字段不变)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 update_calendar_eventlark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 2;tool 已校验;calendar/get_calendar_detail、calendar/update_calendar_event
输入 / CLI 输出
输入
dws calendar +reschedule --event x --start x --end x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "endDateTime": "x",
-    "eventId": "x",
-    "startDateTime": "x"
-  },
-  "product": "calendar",
-  "tool": "update_calendar_event"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "endDateTime": "x",
-    "eventId": "x",
-    "startDateTime": "x"
-  },
-  "product": "calendar",
-  "tool": "update_calendar_event"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1960ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +reschedule --event c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09 --start 2026-07-16T11:30:00+08:00 --end 2026-07-16T11:45:00+08:00 --yes --format json
真实 stdout
{
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "success": true
-}
dws calendar +respond-event --event <event> --response <response>write复合/智能型
接受 / 拒绝 / 暂定回复一个日程邀请(作为参会人设置自己的响应状态)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 respondlark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/respond
输入 / CLI 输出
输入
dws calendar +respond-event --event x --response accept --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "eventId": "x",
-    "responseStatus": "accepted"
-  },
-  "product": "calendar",
-  "tool": "respond"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "eventId": "x",
-    "responseStatus": "accepted"
-  },
-  "product": "calendar",
-  "tool": "respond"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1217ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test calendar +respond-event --event x --response accept --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "300000",
-    "server_key": "calendar",
-    "trace_id": "0bb7c36217840999105062121e07db"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "300000",
-    "server_key": "calendar",
-    "trace_id": "0bb7c36217840999105062121e07db"
-  }
-}
dws calendar +room-find --start <start> --end <end> --available <available> --group-id <group-id> --room-name <room-name> --limit <limit> --page <page>read
按时间段搜索可用会议室(不传时间默认当前起 1 小时)
dws mcp calendar query_available_meeting_room --json '{"startTime":<..>, "endTime":<..>, "needAvailable":<..>, "groupId":<..>, "roomName":<..>, "pageSize":<..>, "pageIndex":<..>}'lark-cli calendar +room-find实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
输入 / CLI 输出
输入
dws calendar +room-find --start x --end x --available --group-id x --room-name x --limit x --page x --yes
stdout
(空)
stderr / error
invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
最终用于判定的返回
invalid --start time "x": expected ISO-8601 like 2026-03-10T14:00:00+08:00
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1504ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test calendar +room-find --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --available --group-id x --room-name x --limit 10 --page 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "458019",
-    "server_key": "calendar",
-    "trace_id": "2104a64c17840997709913005e0819"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "458019",
-    "server_key": "calendar",
-    "trace_id": "2104a64c17840997709913005e0819"
-  }
-}
dws calendar +room-groups --limit <limit> --page <page>read
会议室分组列表
dws mcp calendar <tool>lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/list_meeting_room_groups
输入 / CLI 输出
输入
dws calendar +room-groups --limit x --page x --yes
stdout
{
-  "count": 0,
-  "groups": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "groups": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1224ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +room-groups --limit 10 --page 1 --yes --format json
真实 stdout
{
-  "count": 0,
-  "groups": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "groups": []
-}
dws calendar +room-search --room-name <room-name>read
按名称模糊搜索会议室(不检查可用性)
dws mcp calendar <tool>lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/search_rooms
输入 / CLI 输出
输入
dws calendar +room-search --room-name x --yes
stdout
{
-  "count": 0,
-  "rooms": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "rooms": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1298ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +room-search --room-name x --yes --format json
真实 stdout
{
-  "count": 0,
-  "rooms": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "rooms": []
-}
dws calendar +suggest-time --with <with> --start <start> --end <end> --duration <duration>read复合/智能型
按姓名解析多位参与者,推荐大家都有空的可开会时间段(自动解析 userId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;以下参与者无法解析为唯一 userId: -- 通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws calendar +suggest-time --with x --start x --end x --duration x --yes
stdout
(空)
stderr / error
以下参与者无法解析为唯一 userId:
-- 通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
以下参与者无法解析为唯一 userId:
-- 通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1711ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +suggest-time --with '冬翔' --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --duration x --yes --format json
真实 stdout
{
-  "suggestions": [
-    {
-      "end": "2026-07-16T10:30:00+08:00",
-      "start": "2026-07-16T10:00:00+08:00"
-    },
-    {
-      "end": "2026-07-16T11:00:00+08:00",
-      "start": "2026-07-16T10:30:00+08:00"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "suggestions": [
-    {
-      "end": "2026-07-16T10:30:00+08:00",
-      "start": "2026-07-16T10:00:00+08:00"
-    },
-    {
-      "end": "2026-07-16T11:00:00+08:00",
-      "start": "2026-07-16T10:30:00+08:00"
-    }
-  ]
-}
dws calendar +todayread复合/智能型
列出我今天的日程(自动计算今天的起止时间,无需手动填时间范围)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/list_calendar_events
输入 / CLI 输出
输入
dws calendar +today --yes
stdout
{
-  "events": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "events": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1951ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +today --yes --format json
真实 stdout
{
-  "events": [
-    {
-      "end": "2026-07-15 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000",
-      "location": "B310",
-      "start": "2026-07-15 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-15 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000",
-      "start": "2026-07-15 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "events": [
-    {
-      "end": "2026-07-15 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000",
-      "location": "B310",
-      "start": "2026-07-15 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-15 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000",
-      "start": "2026-07-15 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    }
-  ]
-}
dws calendar +tomorrowread复合/智能型
列出我明天的日程(自动计算明天的起止时间,无需手动填时间范围)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/list_calendar_events
输入 / CLI 输出
输入
dws calendar +tomorrow --yes
stdout
{
-  "events": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "events": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2254ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +tomorrow --yes --format json
真实 stdout
{
-  "events": [
-    {
-      "end": "2026-07-16 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000",
-      "location": "B310",
-      "start": "2026-07-16 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-16 20:00",
-      "eventId": "cVRhbUZsMzZscU1XQjd0NURFaEdnZz09",
-      "location": "张功馆·桐庐菜(未来星宸永福店)",
-      "start": "2026-07-16 18:00",
-      "title": "搜问&搜索团队聚餐"
-    },
-    {
-      "end": "2026-07-16 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784206800000",
-      "start": "2026-07-16 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "events": [
-    {
-      "end": "2026-07-16 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000",
-      "location": "B310",
-      "start": "2026-07-16 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-16 20:00",
-      "eventId": "cVRhbUZsMzZscU1XQjd0NURFaEdnZz09",
-      "location": "张功馆·桐庐菜(未来星宸永福店)",
-      "start": "2026-07-16 18:00",
-      "title": "搜问&搜索团队聚餐"
-    },
-    {
-      "end": "2026-07-16 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784206800000",
-      "start": "2026-07-16 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    }
-  ]
-}
dws calendar +weekread复合/智能型
列出我本周的日程(自动按周一为周首计算本周起止时间,无需手动填时间范围)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark calendar 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;calendar/list_calendar_events
输入 / CLI 输出
输入
dws calendar +week --yes
stdout
{
-  "events": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "events": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=6589ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test calendar +week --yes --format json
真实 stdout
{
-  "events": [
-    {
-      "end": "2026-07-13 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1783905300000",
-      "location": "B310",
-      "start": "2026-07-13 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-13 15:30",
-      "eventId": "UTFidStpV3JUcnVQRDhHZThjZVJGQT09",
-      "start": "2026-07-13 15:00",
-      "title": "llm-wiki的能力共创快速对一下使用说明"
-    },
-    {
-      "end": "2026-07-13 16:15",
-      "eventId": "bjZWVmlieHVpUUJJMm15RGFUMlRIZz09",
-      "start": "2026-07-13 15:30",
-      "title": "聊聊"
-    },
-    {
-      "end": "2026-07-13 16:30",
-      "eventId": "elVnVE45K1YvcStEbEpvM2RxbGVYQT09",
-      "location": "2003",
-      "start": "2026-07-13 16:00",
-      "title": "AI技术平台小周会"
-    },
-    {
-      "end": "2026-07-13 18:00",
-      "eventId": "RXJDc2RMNzNPWkRZRnpzTU1NZ1Nsdz09_1783933200000",
-      "location": "B310",
-      "start": "2026-07-13 17:00",
-      "title": "AI营销周会"
-    },
-    {
-      "end": "2026-07-13 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1783947600000",
-      "start": "2026-07-13 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-14 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1783991700000",
-      "location": "B310",
-      "start": "2026-07-14 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-14 14:30",
-      "eventId": "d1BpNmZOVkdJaFY5dXdIVE9SdkZPQT09",
-      "location": "D1-2001",
-      "start": "2026-07-14 13:30",
-      "title": "简单聊一聊"
-    },
-    {
-      "end": "2026-07-14 20:30",
-      "eventId": "ZUtBTlQxNjY3TnJKM0JpVEx2T1Ardz09",
-      "location": "D1-2004",
-      "start": "2026-07-14 19:30",
-      "title": "盘一下算法 x 搜索搜问合作"
-    },
-    {
-      "end": "2026-07-14 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784034000000",
-      "start": "2026-07-14 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-15 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000",
-      "location": "B310",
-      "start": "2026-07-15 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-15 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000",
-      "start": "2026-07-15 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-16 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000",
-      "location": "B310",
-      "start": "2026-07-16 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-16 20:00",
-      "eventId": "cVRhbUZsMzZscU1XQjd0NURFaEdnZz09",
-      "location": "张功馆·桐庐菜(未来星宸永福店)",
-      "start": "2026-07-16 18:00",
-      "title": "搜问&搜索团队聚餐"
-    },
-    {
-      "end": "2026-07-16 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784206800000",
-      "start": "2026-07-16 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-17 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784250900000",
-      "location": "B310",
-      "start": "2026-07-17 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-17 18:00",
-      "eventId": "bmVEbnArV25ITStSQlE5WU1QOHhJUT09_1784278800000",
-      "location": "项目室(D1-1604)",
-      "start": "2026-07-17 17:00",
-      "title": "模型算法团队周会"
-    },
-    {
-      "end": "2026-07-17 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784293200000",
-      "start": "2026-07-17 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "events": [
-    {
-      "end": "2026-07-13 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1783905300000",
-      "location": "B310",
-      "start": "2026-07-13 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-13 15:30",
-      "eventId": "UTFidStpV3JUcnVQRDhHZThjZVJGQT09",
-      "start": "2026-07-13 15:00",
-      "title": "llm-wiki的能力共创快速对一下使用说明"
-    },
-    {
-      "end": "2026-07-13 16:15",
-      "eventId": "bjZWVmlieHVpUUJJMm15RGFUMlRIZz09",
-      "start": "2026-07-13 15:30",
-      "title": "聊聊"
-    },
-    {
-      "end": "2026-07-13 16:30",
-      "eventId": "elVnVE45K1YvcStEbEpvM2RxbGVYQT09",
-      "location": "2003",
-      "start": "2026-07-13 16:00",
-      "title": "AI技术平台小周会"
-    },
-    {
-      "end": "2026-07-13 18:00",
-      "eventId": "RXJDc2RMNzNPWkRZRnpzTU1NZ1Nsdz09_1783933200000",
-      "location": "B310",
-      "start": "2026-07-13 17:00",
-      "title": "AI营销周会"
-    },
-    {
-      "end": "2026-07-13 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1783947600000",
-      "start": "2026-07-13 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-14 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1783991700000",
-      "location": "B310",
-      "start": "2026-07-14 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-14 14:30",
-      "eventId": "d1BpNmZOVkdJaFY5dXdIVE9SdkZPQT09",
-      "location": "D1-2001",
-      "start": "2026-07-14 13:30",
-      "title": "简单聊一聊"
-    },
-    {
-      "end": "2026-07-14 20:30",
-      "eventId": "ZUtBTlQxNjY3TnJKM0JpVEx2T1Ardz09",
-      "location": "D1-2004",
-      "start": "2026-07-14 19:30",
-      "title": "盘一下算法 x 搜索搜问合作"
-    },
-    {
-      "end": "2026-07-14 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784034000000",
-      "start": "2026-07-14 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-15 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000",
-      "location": "B310",
-      "start": "2026-07-15 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-15 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000",
-      "start": "2026-07-15 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-16 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000",
-      "location": "B310",
-      "start": "2026-07-16 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-16 20:00",
-      "eventId": "cVRhbUZsMzZscU1XQjd0NURFaEdnZz09",
-      "location": "张功馆·桐庐菜(未来星宸永福店)",
-      "start": "2026-07-16 18:00",
-      "title": "搜问&搜索团队聚餐"
-    },
-    {
-      "end": "2026-07-16 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784206800000",
-      "start": "2026-07-16 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    },
-    {
-      "end": "2026-07-17 10:00",
-      "eventId": "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784250900000",
-      "location": "B310",
-      "start": "2026-07-17 09:15",
-      "title": "中控专属钉&AI项目早会"
-    },
-    {
-      "end": "2026-07-17 18:00",
-      "eventId": "bmVEbnArV25ITStSQlE5WU1QOHhJUT09_1784278800000",
-      "location": "项目室(D1-1604)",
-      "start": "2026-07-17 17:00",
-      "title": "模型算法团队周会"
-    },
-    {
-      "end": "2026-07-17 21:15",
-      "eventId": "cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784293200000",
-      "start": "2026-07-17 21:00",
-      "title": "循环提醒:项目每日工作进展同步"
-    }
-  ]
-}
-

doc · 19 个 · ↔ lark doc

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws doc +comment-create --node <node> --content <content> --mention <mention>write
在文档上创建一条评论
dws mcp doc create_comment --json '{"nodeId":<..>, "content":<..>, "mentionedUserIds":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc-comment/create_comment
输入 / CLI 输出
输入
dws doc +comment-create --node x --content x --mention x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "content": "x",
-    "mentionedUserIds": [
-      "x"
-    ],
-    "nodeId": "x"
-  },
-  "product": "doc-comment",
-  "tool": "create_comment"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "content": "x",
-    "mentionedUserIds": [
-      "x"
-    ],
-    "nodeId": "x"
-  },
-  "product": "doc-comment",
-  "tool": "create_comment"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2263ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +comment-create --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --content 'DWS shortcut 真实测试 20260715:验证 doc +comment-create,可忽略。' --yes --format json
真实 stdout
{
-  "commentKey": "1784082136009b316ba6c48ad41ad92a8b6905406a17c",
-  "logId": "2132f5ca17840821358717098e08d7",
-  "message": "评论创建成功。commentKey: 1784082136009b316ba6c48ad41ad92a8b6905406a17c",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "commentKey": "1784082136009b316ba6c48ad41ad92a8b6905406a17c",
-  "logId": "2132f5ca17840821358717098e08d7",
-  "message": "评论创建成功。commentKey: 1784082136009b316ba6c48ad41ad92a8b6905406a17c",
-  "success": true
-}
dws doc +comment-create-inline --node <node> --content <content> --block-id <block-id> --start <start> --end <end> --selected-text <selected-text> --mention <mention>write
在文档选中文本区域上创建划词评论
dws mcp doc create_inline_comment --json '{"nodeId":<..>, "content":<..>, "blockId":<..>, "start":<..>, "end":<..>, "selectedText":<..>, "mentionedUserIds":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc-comment/create_inline_comment
输入 / CLI 输出
输入
dws doc +comment-create-inline --node x --content x --block-id x --start 1 --end 1 --selected-text x --mention x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "blockId": "x",
-    "content": "x",
-    "end": 1,
-    "mentionedUserIds": [
-      "x"
-    ],
-    "nodeId": "x",
-    "selectedText": "x",
-    "start": 1
-  },
-  "product": "doc-comment",
-  "tool": "create_inline_comment"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "blockId": "x",
-    "content": "x",
-    "end": 1,
-    "mentionedUserIds": [
-      "x"
-    ],
-    "nodeId": "x",
-    "selectedText": "x",
-    "start": 1
-  },
-  "product": "doc-comment",
-  "tool": "create_inline_comment"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1183ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test doc +comment-create-inline --node DWSREALTESTNOSUCHID0000000000000 --content 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --block-id DWSREALTESTNOSUCHID0000000000000 --start 0 --end 1 --selected-text 'DWS测试' --mention x --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment)\n  hint: Document may have been deleted or moved",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc-comment",
-    "trace_id": "2127d89817840999689931893e079c"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment)\n  hint: Document may have been deleted or moved",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc-comment",
-    "trace_id": "2127d89817840999689931893e079c"
-  }
-}
dws doc +comment-list --node <node> --limit <limit> --cursor <cursor> --type <type> --resolve-status <resolve-status>read
查询文档评论列表
dws mcp doc list_comments --json '{"nodeId":<..>, "pageSize":<..>, "nextToken":<..>, "commentType":<..>, "resolveStatus":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc-comment/list_comments
输入 / CLI 输出
输入
dws doc +comment-list --node x --limit 1 --cursor x --type global --resolve-status resolved --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "commentType": "global",
-    "nextToken": "x",
-    "nodeId": "x",
-    "pageSize": 1,
-    "resolveStatus": "resolved"
-  },
-  "product": "doc-comment",
-  "tool": "list_comments"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "commentType": "global",
-    "nextToken": "x",
-    "nodeId": "x",
-    "pageSize": 1,
-    "resolveStatus": "resolved"
-  },
-  "product": "doc-comment",
-  "tool": "list_comments"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2579ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +comment-list --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --limit 10 --type global --yes --format json
真实 stdout
{
-  "commentList": [
-    {
-      "commentKey": "1784082136009b316ba6c48ad41ad92a8b6905406a17c",
-      "content": "DWS shortcut 真实测试 20260715:验证 doc +comment-create,可忽略。",
-      "createTime": 1784082136009,
-      "isGlobal": true,
-      "isSolved": false,
-      "quote": null,
-      "updateTime": 1784082136009
-    },
-    {
-      "commentKey": "1784082170012979f5c547f7a49e88a6c1dd718bbb304",
-      "content": "DWS shortcut 真实测试 20260715:验证 doc +comment-reply,可忽略。",
-      "createTime": 1784082170012,
-      "isGlobal": true,
-      "isSolved": false,
-      "quote": null,
-      "updateTime": 1784082170012
-    }
-  ],
-  "hasMore": false,
-  "logId": "213ee25c17840994077796280e0923",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "commentList": [
-    {
-      "commentKey": "1784082136009b316ba6c48ad41ad92a8b6905406a17c",
-      "content": "DWS shortcut 真实测试 20260715:验证 doc +comment-create,可忽略。",
-      "createTime": 1784082136009,
-      "isGlobal": true,
-      "isSolved": false,
-      "quote": null,
-      "updateTime": 1784082136009
-    },
-    {
-      "commentKey": "1784082170012979f5c547f7a49e88a6c1dd718bbb304",
-      "content": "DWS shortcut 真实测试 20260715:验证 doc +comment-reply,可忽略。",
-      "createTime": 1784082170012,
-      "isGlobal": true,
-      "isSolved": false,
-      "quote": null,
-      "updateTime": 1784082170012
-    }
-  ],
-  "hasMore": false,
-  "logId": "213ee25c17840994077796280e0923",
-  "success": true
-}
dws doc +comment-reply --node <node> --content <content> --comment-key <comment-key> --emoji <emoji> --mention <mention>write
回复文档中的一条评论
dws mcp doc reply_comment --json '{"nodeId":<..>, "content":<..>, "replyCommentKey":<..>, "emoji":<..>, "mentionedUserIds":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc-comment/reply_comment
输入 / CLI 输出
输入
dws doc +comment-reply --node x --content x --comment-key x --emoji --mention x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "content": "x",
-    "emoji": true,
-    "mentionedUserIds": [
-      "x"
-    ],
-    "nodeId": "x",
-    "replyCommentKey": "x"
-  },
-  "product": "doc-comment",
-  "tool": "reply_comment"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "content": "x",
-    "emoji": true,
-    "mentionedUserIds": [
-      "x"
-    ],
-    "nodeId": "x",
-    "replyCommentKey": "x"
-  },
-  "product": "doc-comment",
-  "tool": "reply_comment"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1553ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +comment-reply --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --comment-key 1784082136009b316ba6c48ad41ad92a8b6905406a17c --content 'DWS shortcut 真实测试 20260715:验证 doc +comment-reply,可忽略。' --yes --format json
真实 stdout
{
-  "commentKey": "1784082170012979f5c547f7a49e88a6c1dd718bbb304",
-  "logId": "2106d98117840821697518379e089c",
-  "message": "回复评论成功。commentKey: 1784082170012979f5c547f7a49e88a6c1dd718bbb304",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "commentKey": "1784082170012979f5c547f7a49e88a6c1dd718bbb304",
-  "logId": "2106d98117840821697518379e089c",
-  "message": "回复评论成功。commentKey: 1784082170012979f5c547f7a49e88a6c1dd718bbb304",
-  "success": true
-}
dws doc +copy --node <node> --folder <folder> --workspace <workspace>write
复制文档/文件到指定文件夹或知识库
dws mcp doc copy_document --json '{"nodeId":<..>, "targetFolderId":<..>, "workspaceId":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/copy_document
输入 / CLI 输出
输入
dws doc +copy --node x --folder x --workspace x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "copy_document"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "copy_document"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1531ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +copy --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json
真实 stdout
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-  "message": "节点复制成功,已复制到目标文件夹。sourceNodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ,新节点 ID: 7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-  "nodeId": "7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-  "message": "节点复制成功,已复制到目标文件夹。sourceNodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ,新节点 ID: 7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-  "nodeId": "7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-  "success": true
-}
dws doc +doc-append --doc <doc> --text <text>write复合/智能型
在文档末尾追加一段文本(安全追加,不改动原有内容)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 update_documentlark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/update_document
输入 / CLI 输出
输入
dws doc +doc-append --doc x --text x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "markdown": "x",
-    "mode": "append",
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "update_document"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "markdown": "x",
-    "mode": "append",
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "update_document"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2892ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +doc-append --doc P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --text 'DWS shortcut 真实测试 20260715:验证 doc +doc-append。' --yes --format json
真实 stdout
{
-  "logId": "213ee25c17840821367055154e08a0",
-  "message": "内容已成功追加到文档末尾,原有内容保持不变。",
-  "mode": "append",
-  "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "logId": "213ee25c17840821367055154e08a0",
-  "message": "内容已成功追加到文档末尾,原有内容保持不变。",
-  "mode": "append",
-  "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "success": true
-}
dws doc +export-get --job-id <job-id>read
根据 jobId 查询文档导出任务结果
dws mcp doc query_export_job --json '{"jobId":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/query_export_job
输入 / CLI 输出
输入
dws doc +export-get --job-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "jobId": "x"
-  },
-  "product": "doc",
-  "tool": "query_export_job"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "jobId": "x"
-  },
-  "product": "doc",
-  "tool": "query_export_job"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1379ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +export-get --job-id 29346731713 --yes --format json
真实 stdout
{
-  "downloadUrl": "[REDACTED_OSS_URL]",
-  "jobId": "29346731713",
-  "logId": "2132f5ca17840994092582121e08d9",
-  "message": "Export job completed. The file is ready for download via the downloadUrl.",
-  "status": "success",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "downloadUrl": "[REDACTED_OSS_URL]",
-  "jobId": "29346731713",
-  "logId": "2132f5ca17840994092582121e08d9",
-  "message": "Export job completed. The file is ready for download via the downloadUrl.",
-  "status": "success",
-  "success": true
-}
dws doc +export-submit --node <node> --export-format <export-format>read
提交在线文档导出任务 (docx/markdown/pdf),返回 jobId
dws mcp doc submit_export_job --json '{"nodeId":<..>, "exportFormat":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/submit_export_job
输入 / CLI 输出
输入
dws doc +export-submit --node x --export-format docx --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "exportFormat": "docx",
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "submit_export_job"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "exportFormat": "docx",
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "submit_export_job"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1573ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +export-submit --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --export-format docx --yes --format json
真实 stdout
{
-  "jobId": "29346731713",
-  "logId": "2132f5ca17840994104887005e08d8",
-  "message": "Export job submitted successfully. Use query_export_job with jobId '29346731713' to check the export status and get the download link.",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "jobId": "29346731713",
-  "logId": "2132f5ca17840994104887005e08d8",
-  "message": "Export job submitted successfully. Use query_export_job with jobId '29346731713' to check the export status and get the download link.",
-  "success": true
-}
dws doc +find-doc --query <query> --limit <limit>read复合/智能型
按关键词搜索云文档并投影关键字段(只读)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/search_documents;没搜到文档
输入 / CLI 输出
输入
dws doc +find-doc --query x --limit 1 --yes
stdout
(空)
stderr / error
没搜到文档
最终用于判定的返回
没搜到文档
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2146ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +find-doc --query '测试' --limit 10 --yes --format json
真实 stdout
{
-  "count": 10,
-  "documents": [
-    {
-      "title": "DWS shortcut 真实测试文档 20260715",
-      "token": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "title": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "token": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试 -20260414",
-      "token": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(25)",
-      "token": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(5)",
-      "token": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(24)",
-      "token": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(22)",
-      "token": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(26)(2)",
-      "token": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space"
-    },
-    {
-      "title": "悟空DWS全部测试账号信息",
-      "token": "X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space"
-    },
-    {
-      "title": "测试",
-      "token": "1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 10,
-  "documents": [
-    {
-      "title": "DWS shortcut 真实测试文档 20260715",
-      "token": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "title": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "token": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试 -20260414",
-      "token": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(25)",
-      "token": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(5)",
-      "token": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(24)",
-      "token": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(22)",
-      "token": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "title": "文件扫描测试(26)(2)",
-      "token": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space"
-    },
-    {
-      "title": "悟空DWS全部测试账号信息",
-      "token": "X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space"
-    },
-    {
-      "title": "测试",
-      "token": "1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE",
-      "type": "",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space"
-    }
-  ]
-}
dws doc +list --folder <folder> --workspace <workspace> --limit <limit> --cursor <cursor>read
列出文件夹或知识库下的直接子节点
dws mcp doc <tool>lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/list_nodes
输入 / CLI 输出
输入
dws doc +list --folder x --workspace x --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "nodes": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "nodes": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2075ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +list --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --limit 10 --yes --format json
真实 stdout
{
-  "count": 10,
-  "nodes": [
-    {
-      "name": "DWS shortcut 真实测试文档 20260715(2)",
-      "nodeId": "np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l?utm_scene=person_space"
-    },
-    {
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "name": "DWS shortcut 真实测试文档 20260715(1)",
-      "nodeId": "7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3?utm_scene=person_space"
-    },
-    {
-      "name": "知识库对接调研问卷-供各业务系统填写",
-      "nodeId": "R4GpnMqJzOP0oLp6skZ0Kmvp8Ke0xjE3",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/R4GpnMqJzOP0oLp6skZ0Kmvp8Ke0xjE3?utm_scene=person_space"
-    },
-    {
-      "name": "无标题文档(2)",
-      "nodeId": "X6GRezwJlYxMoDeBsgX95gYj8dqbropQ",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsgX95gYj8dqbropQ?utm_scene=person_space"
-    },
-    {
-      "name": "hi",
-      "nodeId": "1zknDm0WRz0NZeXQu01lGOdmWBQEx5rG",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1zknDm0WRz0NZeXQu01lGOdmWBQEx5rG?utm_scene=person_space"
-    },
-    {
-      "name": "T(2)",
-      "nodeId": "dxXB52LJqwZrolvyFKLy25D9WqjMp697",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/dxXB52LJqwZrolvyFKLy25D9WqjMp697?utm_scene=person_space"
-    },
-    {
-      "name": "T(1)",
-      "nodeId": "mweZ92PV6O36dZbnsML6RyaAJxEKBD6p",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/mweZ92PV6O36dZbnsML6RyaAJxEKBD6p?utm_scene=person_space"
-    },
-    {
-      "name": "T",
-      "nodeId": "4lgGw3P8vwYmoljyFg90Z2X9W5daZ90D",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/4lgGw3P8vwYmoljyFg90Z2X9W5daZ90D?utm_scene=person_space"
-    },
-    {
-      "name": "念晨与hugozhu的会话 2026年06月22日",
-      "nodeId": "GZLxjv9VGGDA9Ro2u6yKZ7BxV6EDybno",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/GZLxjv9VGGDA9Ro2u6yKZ7BxV6EDybno?utm_scene=person_space"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 10,
-  "nodes": [
-    {
-      "name": "DWS shortcut 真实测试文档 20260715(2)",
-      "nodeId": "np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l?utm_scene=person_space"
-    },
-    {
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "name": "DWS shortcut 真实测试文档 20260715(1)",
-      "nodeId": "7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3?utm_scene=person_space"
-    },
-    {
-      "name": "知识库对接调研问卷-供各业务系统填写",
-      "nodeId": "R4GpnMqJzOP0oLp6skZ0Kmvp8Ke0xjE3",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/R4GpnMqJzOP0oLp6skZ0Kmvp8Ke0xjE3?utm_scene=person_space"
-    },
-    {
-      "name": "无标题文档(2)",
-      "nodeId": "X6GRezwJlYxMoDeBsgX95gYj8dqbropQ",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsgX95gYj8dqbropQ?utm_scene=person_space"
-    },
-    {
-      "name": "hi",
-      "nodeId": "1zknDm0WRz0NZeXQu01lGOdmWBQEx5rG",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1zknDm0WRz0NZeXQu01lGOdmWBQEx5rG?utm_scene=person_space"
-    },
-    {
-      "name": "T(2)",
-      "nodeId": "dxXB52LJqwZrolvyFKLy25D9WqjMp697",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/dxXB52LJqwZrolvyFKLy25D9WqjMp697?utm_scene=person_space"
-    },
-    {
-      "name": "T(1)",
-      "nodeId": "mweZ92PV6O36dZbnsML6RyaAJxEKBD6p",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/mweZ92PV6O36dZbnsML6RyaAJxEKBD6p?utm_scene=person_space"
-    },
-    {
-      "name": "T",
-      "nodeId": "4lgGw3P8vwYmoljyFg90Z2X9W5daZ90D",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/4lgGw3P8vwYmoljyFg90Z2X9W5daZ90D?utm_scene=person_space"
-    },
-    {
-      "name": "念晨与hugozhu的会话 2026年06月22日",
-      "nodeId": "GZLxjv9VGGDA9Ro2u6yKZ7BxV6EDybno",
-      "nodeType": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/GZLxjv9VGGDA9Ro2u6yKZ7BxV6EDybno?utm_scene=person_space"
-    }
-  ]
-}
dws doc +move --node <node> --folder <folder> --workspace <workspace>write
移动文档/文件到指定文件夹或知识库
dws mcp doc move_document --json '{"nodeId":<..>, "targetFolderId":<..>, "workspaceId":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/move_document
输入 / CLI 输出
输入
dws doc +move --node x --folder x --workspace x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "move_document"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "move_document"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1087ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +move --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json
真实 stdout
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "message": "节点移动成功,已移动到目标文件夹。nodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ",
-  "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "message": "节点移动成功,已移动到目标文件夹。nodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ",
-  "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "success": true
-}
dws doc +search --query <query> --extensions <extensions> --created-from <created-from> --created-to <created-to> --visited-from <visited-from> --visited-to <visited-to> --creator-uids <creator-uids> --editor-uids <editor-uids> --mentioned-uids <mentioned-uids> --workspace-ids <workspace-ids> --limit <limit> --cursor <cursor>read
按关键词搜索有权限的文档 (不传则返回最近访问)
dws mcp doc <tool>lark-cli doc +search已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/search_documents
输入 / CLI 输出
输入
dws doc +search --query x --extensions x --created-from 1 --created-to 1 --visited-from 1 --visited-to 1 --creator-uids x --editor-uids x --mentioned-uids x --workspace-ids x --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "documents": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "documents": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1911ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +search --query '测试' --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 10,
-  "documents": [
-    {
-      "docType": "adoc",
-      "modifiedTime": null,
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "nodeId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "url": "https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试 -20260414",
-      "nodeId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "url": "https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(25)",
-      "nodeId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "url": "https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(5)",
-      "nodeId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "url": "https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(24)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(22)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(26)(2)",
-      "nodeId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "url": "https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space"
-    },
-    {
-      "docType": "adoc",
-      "modifiedTime": null,
-      "name": "悟空DWS全部测试账号信息",
-      "nodeId": "X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space"
-    },
-    {
-      "docType": "folder",
-      "modifiedTime": null,
-      "name": "测试",
-      "nodeId": "1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 10,
-  "documents": [
-    {
-      "docType": "adoc",
-      "modifiedTime": null,
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "nodeId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "url": "https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试 -20260414",
-      "nodeId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "url": "https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(25)",
-      "nodeId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "url": "https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(5)",
-      "nodeId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "url": "https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(24)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(22)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "docType": "able",
-      "modifiedTime": null,
-      "name": "文件扫描测试(26)(2)",
-      "nodeId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "url": "https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space"
-    },
-    {
-      "docType": "adoc",
-      "modifiedTime": null,
-      "name": "悟空DWS全部测试账号信息",
-      "nodeId": "X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space"
-    },
-    {
-      "docType": "folder",
-      "modifiedTime": null,
-      "name": "测试",
-      "nodeId": "1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space"
-    }
-  ]
-}
dws doc +share-doc --to <to> --url <url> --note <note>write复合/智能型
按姓名把文档链接私信发给某人(自动解析 userId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 send_personal_messagelark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws doc +share-doc --to x --url x --note x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1943ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +share-doc --to '冬翔' --url https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --note 'DWS shortcut 修复复测 20260715:验证 doc +share-doc,请忽略。' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openTaskId": "30ETViSW+9qhs7fBO9cbeSocHjj2iUZL4firJPz/h/I="
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openTaskId": "30ETViSW+9qhs7fBO9cbeSocHjj2iUZL4firJPz/h/I="
-  },
-  "success": true
-}
dws doc +template-apply --template-id <template-id> --name <name> --folder <folder> --workspace <workspace>write
使用指定模板创建新文档
dws mcp doc apply_doc_template --json '{"templateId":<..>, "name":<..>, "folderId":<..>, "workspaceId":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/apply_doc_template
输入 / CLI 输出
输入
dws doc +template-apply --template-id x --name x --folder x --workspace x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "folderId": "x",
-    "name": "x",
-    "templateId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "apply_doc_template"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "folderId": "x",
-    "name": "x",
-    "templateId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "apply_doc_template"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1093ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test doc +template-apply --template-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --folder DWSREALTESTNOSUCHID0000000000000 --workspace DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc",
-    "trace_id": "2127d89817840999701186954e0757"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template)\n  hint: Check if the resource exists or if your account has permission",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc",
-    "trace_id": "2127d89817840999701186954e0757"
-  }
-}
dws doc +template-list --source <source> --limit <limit> --cursor <cursor>read
获取文档模板列表
dws mcp doc list_doc_templates --json '{"templateSource":<..>, "maxResults":<..>, "nextCursor":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/list_doc_templates
输入 / CLI 输出
输入
dws doc +template-list --source MY --limit 1 --cursor x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "maxResults": 1,
-    "nextCursor": "x",
-    "templateSource": "MY"
-  },
-  "product": "doc",
-  "tool": "list_doc_templates"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "maxResults": 1,
-    "nextCursor": "x",
-    "templateSource": "MY"
-  },
-  "product": "doc",
-  "tool": "list_doc_templates"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1370ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +template-list --source MY --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "hasMore": false,
-  "logId": "213ee25c17840994183018652e08e2",
-  "message": "成功获取 3 个模版。",
-  "success": true,
-  "templates": [
-    {
-      "coverUrl": "[REDACTED_OSS_URL]",
-      "description": null,
-      "templateId": "494040872345",
-      "title": "接口定义模版v1.0",
-      "usedCount": null
-    },
-    {
-      "coverUrl": "[REDACTED_OSS_URL]",
-      "description": null,
-      "templateId": "494801282345",
-      "title": "接口定义与描述模版",
-      "usedCount": null
-    },
-    {
-      "coverUrl": "[REDACTED_OSS_URL]",
-      "description": null,
-      "templateId": "111001402345",
-      "title": "2023届钉钉音视频春招海报",
-      "usedCount": null
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "hasMore": false,
-  "logId": "213ee25c17840994183018652e08e2",
-  "message": "成功获取 3 个模版。",
-  "success": true,
-  "templates": [
-    {
-      "coverUrl": "[REDACTED_OSS_URL]",
-      "description": null,
-      "templateId": "494040872345",
-      "title": "接口定义模版v1.0",
-      "usedCount": null
-    },
-    {
-      "coverUrl": "[REDACTED_OSS_URL]",
-      "description": null,
-      "templateId": "494801282345",
-      "title": "接口定义与描述模版",
-      "usedCount": null
-    },
-    {
-      "coverUrl": "[REDACTED_OSS_URL]",
-      "description": null,
-      "templateId": "111001402345",
-      "title": "2023届钉钉音视频春招海报",
-      "usedCount": null
-    }
-  ]
-}
dws doc +template-search --query <query> --source <source> --limit <limit> --cursor <cursor>read
根据关键词搜索文档模板
dws mcp doc search_doc_templates --json '{"searchName":<..>, "templateSource":<..>, "maxResults":<..>, "nextCursor":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/search_doc_templates
输入 / CLI 输出
输入
dws doc +template-search --query x --source MY --limit 1 --cursor x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "maxResults": 1,
-    "nextCursor": "x",
-    "searchName": "x",
-    "templateSource": "MY"
-  },
-  "product": "doc",
-  "tool": "search_doc_templates"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "maxResults": 1,
-    "nextCursor": "x",
-    "searchName": "x",
-    "templateSource": "MY"
-  },
-  "product": "doc",
-  "tool": "search_doc_templates"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1576ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +template-search --query '测试' --source MY --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "hasMore": false,
-  "logId": "213ee25c17840994199004331e08a0",
-  "message": "未找到与「测试」相关的模版,请尝试其他关键词。",
-  "success": true,
-  "templates": []
-}
真实 stderr / error
(空)
真实返回
{
-  "hasMore": false,
-  "logId": "213ee25c17840994199004331e08a0",
-  "message": "未找到与「测试」相关的模版,请尝试其他关键词。",
-  "success": true,
-  "templates": []
-}
dws doc +version-list --node <node> --limit <limit> --cursor <cursor>read
查看文档历史版本列表
dws mcp doc list_doc_versions --json '{"nodeId":<..>, "maxResults":<..>, "nextCursor":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/list_doc_versions
输入 / CLI 输出
输入
dws doc +version-list --node x --limit 1 --cursor x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "maxResults": 1,
-    "nextCursor": "x",
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "list_doc_versions"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "maxResults": 1,
-    "nextCursor": "x",
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "list_doc_versions"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2069ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +version-list --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "hasMore": false,
-  "success": true,
-  "versions": [
-    {
-      "createTime": "2026-07-15T10:21:47+08:00",
-      "type": "AUTO_SAVE",
-      "updateTime": "2026-07-15T10:21:47+08:00",
-      "userId": "202397",
-      "version": 0
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "hasMore": false,
-  "success": true,
-  "versions": [
-    {
-      "createTime": "2026-07-15T10:21:47+08:00",
-      "type": "AUTO_SAVE",
-      "updateTime": "2026-07-15T10:21:47+08:00",
-      "userId": "202397",
-      "version": 0
-    }
-  ]
-}
dws doc +version-revert --node <node> --version <version>high-risk-write
回滚文档到指定历史版本
dws mcp doc revert_doc_version --json '{"nodeId":<..>, "version":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/revert_doc_version
输入 / CLI 输出
输入
dws doc +version-revert --node x --version 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "version": 1
-  },
-  "product": "doc",
-  "tool": "revert_doc_version"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "version": 1
-  },
-  "product": "doc",
-  "tool": "revert_doc_version"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2065ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +version-revert --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --version 1 --yes --format json
真实 stdout
{
-  "message": "文档已成功回滚到指定版本。",
-  "revertedToVersion": 1,
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "message": "文档已成功回滚到指定版本。",
-  "revertedToVersion": 1,
-  "success": true
-}
dws doc +version-save --node <node>write
手动保存文档版本快照
dws mcp doc save_doc_version --json '{"nodeId":<..>}'lark doc 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/save_doc_version
输入 / CLI 输出
输入
dws doc +version-save --node x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "save_doc_version"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x"
-  },
-  "product": "doc",
-  "tool": "save_doc_version"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1853ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test doc +version-save --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --yes --format json
真实 stdout
{
-  "message": "文档版本已成功保存。",
-  "savedVersion": 1,
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "message": "文档版本已成功保存。",
-  "savedVersion": 1,
-  "success": true
-}
-

contact · 16 个 · ↔ lark contact

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws contact +by-mobile --mobile <mobile>read复合/智能型
按手机号查询某人的完整资料(自动解析 userId 后取详情)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_user_info_by_user_idslark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_user_by_mobile;没找到绑定手机号 "x" 的用户;确认号码正确、且该人在当前组织通讯录中。
输入 / CLI 输出
输入
dws contact +by-mobile --mobile x --yes
stdout
(空)
stderr / error
没找到绑定手机号 "x" 的用户;确认号码正确、且该人在当前组织通讯录中。
最终用于判定的返回
没找到绑定手机号 "x" 的用户;确认号码正确、且该人在当前组织通讯录中。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1884ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +by-mobile --mobile 13161187007 --yes --format json
真实 stdout
{
-  "result": [
-    {
-      "isAdmin": false,
-      "orgEmployeeModel": {
-        "depts": [
-          {
-            "deptId": 842379556,
-            "deptName": "模型算法"
-          }
-        ],
-        "jobNumber": "202397",
-        "labels": [],
-        "orgAuthEmail": null,
-        "orgId": null,
-        "orgMasterDisplayName": "张俊韶",
-        "orgMasterUserId": "030386",
-        "orgName": "钉钉",
-        "orgTitle": "",
-        "orgUserId": "202397",
-        "orgUserName": "董鑫阳",
-        "positions": [
-          {
-            "deptId": 842379556,
-            "isMain": true,
-            "managerDisplayName": null,
-            "managerStaffId": null,
-            "title": null,
-            "workStation": "浙江-杭州-阿里中心·杭州未科 D1-20-015"
-          }
-        ]
-      }
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "result": [
-    {
-      "isAdmin": false,
-      "orgEmployeeModel": {
-        "depts": [
-          {
-            "deptId": 842379556,
-            "deptName": "模型算法"
-          }
-        ],
-        "jobNumber": "202397",
-        "labels": [],
-        "orgAuthEmail": null,
-        "orgId": null,
-        "orgMasterDisplayName": "张俊韶",
-        "orgMasterUserId": "030386",
-        "orgName": "钉钉",
-        "orgTitle": "",
-        "orgUserId": "202397",
-        "orgUserName": "董鑫阳",
-        "positions": [
-          {
-            "deptId": 842379556,
-            "isMain": true,
-            "managerDisplayName": null,
-            "managerStaffId": null,
-            "title": null,
-            "workStation": "浙江-杭州-阿里中心·杭州未科 D1-20-015"
-          }
-        ]
-      }
-    }
-  ],
-  "success": true
-}
dws contact +dept-members --dept <dept>read复合/智能型
按部门名列出部门成员(自动解析 deptId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_dept_members_by_deptIdlark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_dept_by_keyword;没找到名字里包含 "x" 的部门;换个更准确的部门名再试。
输入 / CLI 输出
输入
dws contact +dept-members --dept x --yes
stdout
(空)
stderr / error
没找到名字里包含 "x" 的部门;换个更准确的部门名再试。
最终用于判定的返回
没找到名字里包含 "x" 的部门;换个更准确的部门名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1539ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +dept-members --dept '模型算法' --yes --format json
真实 stdout
{
-  "deptUserList": [
-    {
-      "userInfo": {
-        "name": "冯铖喆",
-        "userId": "453559"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "何佳瑶",
-        "userId": "548493"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "陈梦媛",
-        "userId": "378607"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "张俊韶",
-        "userId": "030386"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "陈邦杰",
-        "userId": "489149"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "王昊东",
-        "userId": "548778"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "岑锦锋",
-        "userId": "547857"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "彭智韩",
-        "userId": "478801"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "王迎湃",
-        "userId": "543875"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "段旭彬",
-        "userId": "550582"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "牛康佳",
-        "userId": "484887"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "符咏畅",
-        "userId": "519483"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "代成俊",
-        "userId": "167095"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "黄渊卓",
-        "userId": "519019"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "董鑫阳",
-        "userId": "202397"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "崔佳骐",
-        "userId": "534975"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "郑琪欣",
-        "userId": "543310"
-      }
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "deptUserList": [
-    {
-      "userInfo": {
-        "name": "冯铖喆",
-        "userId": "453559"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "何佳瑶",
-        "userId": "548493"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "陈梦媛",
-        "userId": "378607"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "张俊韶",
-        "userId": "030386"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "陈邦杰",
-        "userId": "489149"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "王昊东",
-        "userId": "548778"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "岑锦锋",
-        "userId": "547857"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "彭智韩",
-        "userId": "478801"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "王迎湃",
-        "userId": "543875"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "段旭彬",
-        "userId": "550582"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "牛康佳",
-        "userId": "484887"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "符咏畅",
-        "userId": "519483"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "代成俊",
-        "userId": "167095"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "黄渊卓",
-        "userId": "519019"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "董鑫阳",
-        "userId": "202397"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "崔佳骐",
-        "userId": "534975"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "郑琪欣",
-        "userId": "543310"
-      }
-    }
-  ],
-  "success": true
-}
dws contact +get-roster --staff-id <staff-id> --fields <fields>read
查询员工花名册字段信息(学历、家庭、银行卡、合同等)
dws mcp hrmregister get_authorized_emp_rosterInfo --json '{"staffId":<..>, "fieldCodeList":<..>}'lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;hrmregister/get_authorized_emp_rosterInfo
输入 / CLI 输出
输入
dws contact +get-roster --staff-id x --fields x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "fieldCodeList": [
-      "x"
-    ],
-    "staffId": "x"
-  },
-  "product": "hrmregister",
-  "tool": "get_authorized_emp_rosterInfo"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "fieldCodeList": [
-      "x"
-    ],
-    "staffId": "x"
-  },
-  "product": "hrmregister",
-  "tool": "get_authorized_emp_rosterInfo"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1895ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test contact +get-roster --staff-id x --fields x --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "2001",
-    "server_key": "hrmregister",
-    "trace_id": "2106d98117840997920166915e087b"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "2001",
-    "server_key": "hrmregister",
-    "trace_id": "2106d98117840997920166915e087b"
-  }
-}
dws contact +list-dept-members --depts <depts>read
查看部门成员(仅本部门,不含下级)
dws mcp contact <tool>lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_dept_members_by_deptId
输入 / CLI 输出
输入
dws contact +list-dept-members --depts x --yes
stdout
{
-  "count": 0,
-  "members": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "members": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=965ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +list-dept-members --depts x --yes --format json
真实 stdout
{
-  "count": 0,
-  "members": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "members": []
-}
dws contact +list-followingsread
获取当前用户的特别关注列表
dws mcp contact <tool>lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/list_my_followings
输入 / CLI 输出
输入
dws contact +list-followings --yes
stdout
{
-  "count": 0,
-  "followings": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "followings": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1658ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +list-followings --yes --format json
真实 stdout
{
-  "count": 13,
-  "followings": [
-    {
-      "openDingTalkId": "D1jIqGatuH3UbjK1S0jAZJrukgiPdVtRlZ"
-    },
-    {
-      "openDingTalkId": "DS8iSxaOjF5pjvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3UrNUuSZbqakTnUpQ3uwcViS"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3X0M3DbwmJLFJDaK7t2NhxA"
-    },
-    {
-      "openDingTalkId": "D51G4GOo4SMLvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "DHpuMVMNL77HvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "DO3r8ZwxEZ7iSvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "DbQkLTHgGzOiSvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3UgB25OaELcBlHqiPGB82YtU"
-    },
-    {
-      "openDingTalkId": "D8y2OTDIEjFjWZ8DyfnKC9lug4rSiPJt8I"
-    },
-    {
-      "openDingTalkId": "DepgurAiSOr3bvpshro641ZyKOpvhZI2tI"
-    },
-    {
-      "openDingTalkId": "DtMlaaX4m2p8qiPOT4eDpd1MUBtiiFg6Xhx"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3U4ekOiiRj3wcvSfoaqqfvEb"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 13,
-  "followings": [
-    {
-      "openDingTalkId": "D1jIqGatuH3UbjK1S0jAZJrukgiPdVtRlZ"
-    },
-    {
-      "openDingTalkId": "DS8iSxaOjF5pjvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3UrNUuSZbqakTnUpQ3uwcViS"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3X0M3DbwmJLFJDaK7t2NhxA"
-    },
-    {
-      "openDingTalkId": "D51G4GOo4SMLvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "DHpuMVMNL77HvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "DO3r8ZwxEZ7iSvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "DbQkLTHgGzOiSvmTc3wM85awiEiE"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3UgB25OaELcBlHqiPGB82YtU"
-    },
-    {
-      "openDingTalkId": "D8y2OTDIEjFjWZ8DyfnKC9lug4rSiPJt8I"
-    },
-    {
-      "openDingTalkId": "DepgurAiSOr3bvpshro641ZyKOpvhZI2tI"
-    },
-    {
-      "openDingTalkId": "DtMlaaX4m2p8qiPOT4eDpd1MUBtiiFg6Xhx"
-    },
-    {
-      "openDingTalkId": "D1jIqGatuH3U4ekOiiRj3wcvSfoaqqfvEb"
-    }
-  ]
-}
dws contact +list-role-members --id <id>read
查询角色下的成员列表
dws mcp contact <tool>lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_label_members_by_labelId
输入 / CLI 输出
输入
dws contact +list-role-members --id x --yes
stdout
{
-  "count": 0,
-  "members": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "members": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1162ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +list-role-members --id DWSREALREADNOSUCHID0000000000000 --yes --format json
真实 stdout
{
-  "count": 0,
-  "members": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "members": []
-}
dws contact +list-rolesread
获取企业所有角色(标签)列表
dws mcp contact <tool>lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_org_labels
输入 / CLI 输出
输入
dws contact +list-roles --yes
stdout
{
-  "count": 0,
-  "roles": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "roles": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1227ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +list-roles --yes --format json
真实 stdout
{
-  "count": 0,
-  "roles": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "roles": []
-}
dws contact +list-roster-fieldsread
查询花名册有权限的字段列表
dws mcp hrmregister list_authorized_roster_fieldslark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;hrmregister/list_authorized_roster_fields
输入 / CLI 输出
输入
dws contact +list-roster-fields --yes
stdout
{
-  "_fake": true,
-  "args": {},
-  "product": "hrmregister",
-  "tool": "list_authorized_roster_fields"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {},
-  "product": "hrmregister",
-  "tool": "list_authorized_roster_fields"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1305ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
权限 / 鉴权不足 / 非 CLI 可修
-真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。
真实输入
/private/tmp/dws-real-test contact +list-roster-fields --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "2001",
-    "server_key": "hrmregister",
-    "trace_id": "213ee25c17840997934295673e08e2"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "2001",
-    "server_key": "hrmregister",
-    "trace_id": "213ee25c17840997934295673e08e2"
-  }
-}
dws contact +list-sub-depts --dept <dept>read
查看指定部门的子部门
dws mcp contact <tool>lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_sub_depts_by_dept_id
输入 / CLI 输出
输入
dws contact +list-sub-depts --dept 1 --yes
stdout
{
-  "count": 0,
-  "depts": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "depts": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1217ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +list-sub-depts --dept 842379556 --yes --format json
真实 stdout
{
-  "count": 0,
-  "depts": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "depts": []
-}
dws contact +lookup --name <name>read复合/智能型
按姓名查询某人的完整资料(自动解析 userId 后取详情)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_user_info_by_user_idslark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws contact +lookup --name x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1698ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +lookup --name '冬翔' --yes --format json
真实 stdout
{
-  "result": [
-    {
-      "isAdmin": true,
-      "orgEmployeeModel": {
-        "depts": [
-          {
-            "deptId": 996530034,
-            "deptName": "AI终端与创新"
-          }
-        ],
-        "jobNumber": "103262",
-        "labels": [
-          {
-            "groupName": "默认",
-            "id": 2679876691,
-            "name": "子管理员",
-            "tagGroupCode": "2679876691"
-          },
-          {
-            "groupName": "默认",
-            "id": 2679876693,
-            "name": "主管",
-            "tagGroupCode": "2679876693"
-          }
-        ],
-        "orgAuthEmail": null,
-        "orgId": null,
-        "orgMasterDisplayName": "朱鸿",
-        "orgMasterUserId": "024083",
-        "orgName": "钉钉",
-        "orgTitle": "",
-        "orgUserId": "103262",
-        "orgUserName": "夏东翔",
-        "positions": [
-          {
-            "deptId": 996530034,
-            "isMain": true,
-            "managerDisplayName": null,
-            "managerStaffId": null,
-            "title": null,
-            "workStation": "浙江-杭州-阿里中心·杭州未科 "
-          }
-        ]
-      }
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "result": [
-    {
-      "isAdmin": true,
-      "orgEmployeeModel": {
-        "depts": [
-          {
-            "deptId": 996530034,
-            "deptName": "AI终端与创新"
-          }
-        ],
-        "jobNumber": "103262",
-        "labels": [
-          {
-            "groupName": "默认",
-            "id": 2679876691,
-            "name": "子管理员",
-            "tagGroupCode": "2679876691"
-          },
-          {
-            "groupName": "默认",
-            "id": 2679876693,
-            "name": "主管",
-            "tagGroupCode": "2679876693"
-          }
-        ],
-        "orgAuthEmail": null,
-        "orgId": null,
-        "orgMasterDisplayName": "朱鸿",
-        "orgMasterUserId": "024083",
-        "orgName": "钉钉",
-        "orgTitle": "",
-        "orgUserId": "103262",
-        "orgUserName": "夏东翔",
-        "positions": [
-          {
-            "deptId": 996530034,
-            "isMain": true,
-            "managerDisplayName": null,
-            "managerStaffId": null,
-            "title": null,
-            "workStation": "浙江-杭州-阿里中心·杭州未科 "
-          }
-        ]
-      }
-    }
-  ],
-  "success": true
-}
dws contact +meread复合/智能型
查看我自己的通讯录资料(姓名/userId/手机/部门/组织,干净投影)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_current_user_profile
输入 / CLI 输出
输入
dws contact +me --yes
stdout
{
-  "_fake": true,
-  "args": {},
-  "product": "contact",
-  "tool": "get_current_user_profile"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {},
-  "product": "contact",
-  "tool": "get_current_user_profile"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1072ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +me --yes --format json
真实 stdout
{
-  "dept": "模型算法",
-  "mobile": "13161187007",
-  "name": "董鑫阳",
-  "org": "钉钉",
-  "userId": "202397"
-}
真实 stderr / error
(空)
真实返回
{
-  "dept": "模型算法",
-  "mobile": "13161187007",
-  "name": "董鑫阳",
-  "org": "钉钉",
-  "userId": "202397"
-}
dws contact +org --name <name>read复合/智能型
按姓名查某人所在部门的详情(自动解析 userId 与 deptId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_dept_info_by_dept_idlark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws contact +org --name x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1627ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +org --name '冬翔' --yes --format json
真实 stdout
{
-  "result": {
-    "deptId": 996530034,
-    "deptName": "AI终端与创新",
-    "memberCount": 5
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "result": {
-    "deptId": 996530034,
-    "deptName": "AI终端与创新",
-    "memberCount": 5
-  },
-  "success": true
-}
dws contact +resolve-dept --name <name>read复合/智能型
按名称搜索部门并解析出唯一 deptId(只读)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_dept_by_keyword;没有找到名称包含 x 的部门
输入 / CLI 输出
输入
dws contact +resolve-dept --name x --yes
stdout
(空)
stderr / error
没有找到名称包含 x 的部门
最终用于判定的返回
没有找到名称包含 x 的部门
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1186ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +resolve-dept --name '模型算法' --yes --format json
真实 stdout
{
-  "deptId": "842379556",
-  "name": "技术平台-模型算法",
-  "resolved": true
-}
真实 stderr / error
(空)
真实返回
{
-  "deptId": "842379556",
-  "name": "技术平台-模型算法",
-  "resolved": true
-}
dws contact +search-mobile --mobile <mobile>read
按手机号搜索通讯录用户
dws mcp contact <tool>lark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_user_by_mobile
输入 / CLI 输出
输入
dws contact +search-mobile --mobile x --yes
stdout
{
-  "count": 0,
-  "users": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "users": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1415ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +search-mobile --mobile 13000000000 --yes --format json
真实 stdout
{
-  "count": 0,
-  "users": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "users": []
-}
dws contact +search-user --query <query>read
按关键词搜索通讯录用户
dws mcp contact <tool>lark-cli contact +search-user已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word
输入 / CLI 输出
输入
dws contact +search-user --query x --yes
stdout
{
-  "count": 0,
-  "users": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "users": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1419ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +search-user --query '测试' --yes --format json
真实 stdout
{
-  "count": 4,
-  "users": [
-    {
-      "flowerName": null,
-      "name": null,
-      "openDingTalkId": "Dn3FQ9n3oCiiNviSya2usICjFug4rSiPJt8I",
-      "title": null,
-      "userId": null
-    },
-    {
-      "flowerName": null,
-      "name": null,
-      "openDingTalkId": "Dxud965XFNRyGCOG0Bwk0pVug4rSiPJt8I",
-      "title": null,
-      "userId": null
-    },
-    {
-      "flowerName": null,
-      "name": null,
-      "openDingTalkId": "D1jIqGatuH3VffnYwNdg1p2LlYiSf05oYii",
-      "title": null,
-      "userId": null
-    },
-    {
-      "flowerName": null,
-      "name": "测试",
-      "openDingTalkId": "D1jIqGatuH3WSeoELOC89rgTx03b5vNii1",
-      "title": "",
-      "userId": "036230684122903146"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 4,
-  "users": [
-    {
-      "flowerName": null,
-      "name": null,
-      "openDingTalkId": "Dn3FQ9n3oCiiNviSya2usICjFug4rSiPJt8I",
-      "title": null,
-      "userId": null
-    },
-    {
-      "flowerName": null,
-      "name": null,
-      "openDingTalkId": "Dxud965XFNRyGCOG0Bwk0pVug4rSiPJt8I",
-      "title": null,
-      "userId": null
-    },
-    {
-      "flowerName": null,
-      "name": null,
-      "openDingTalkId": "D1jIqGatuH3VffnYwNdg1p2LlYiSf05oYii",
-      "title": null,
-      "userId": null
-    },
-    {
-      "flowerName": null,
-      "name": "测试",
-      "openDingTalkId": "D1jIqGatuH3WSeoELOC89rgTx03b5vNii1",
-      "title": "",
-      "userId": "036230684122903146"
-    }
-  ]
-}
dws contact +team --name <name>read复合/智能型
按姓名列出某人所在部门的成员(自动解析 userId 与 deptId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_dept_members_by_deptIdlark contact 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws contact +team --name x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1738ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test contact +team --name '冬翔' --yes --format json
真实 stdout
{
-  "deptUserList": [
-    {
-      "userInfo": {
-        "name": "黄文杰",
-        "userId": "106261"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "夏东翔",
-        "userId": "103262"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "何伟楠",
-        "userId": "106201"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "汪鑫",
-        "userId": "323179"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "刘光宇",
-        "userId": "112161"
-      }
-    }
-  ],
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "deptUserList": [
-    {
-      "userInfo": {
-        "name": "黄文杰",
-        "userId": "106261"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "夏东翔",
-        "userId": "103262"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "何伟楠",
-        "userId": "106201"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "汪鑫",
-        "userId": "323179"
-      }
-    },
-    {
-      "userInfo": {
-        "name": "刘光宇",
-        "userId": "112161"
-      }
-    }
-  ],
-  "success": true
-}
-

minutes · 13 个 · ↔ lark minutes

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws minutes +action-itemsread复合/智能型
取我最新一条妙记里的待办事项
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 list_minutes_todoslark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/list_by_keyword_and_time_range;暂无妙记
输入 / CLI 输出
输入
dws minutes +action-items --yes
stdout
(空)
stderr / error
暂无妙记
最终用于判定的返回
暂无妙记
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1286ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺妙记/听记真实 fixture / 需真实 fixture,非 CLI 可直接修
-当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。
真实输入
/private/tmp/dws-real-test minutes +action-items --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无妙记"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无妙记"
-  }
-}
dws minutes +detail --id <id> --artifacts <artifacts> --direction <direction>read复合/智能型
一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark-cli minutes +detail实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);未知的 --artifacts 取值 "x",可选: basic,summary,keywords,transcript,todos
输入 / CLI 输出
输入
dws minutes +detail --id x --artifacts x --direction x --yes
stdout
(空)
stderr / error
未知的 --artifacts 取值 "x",可选: basic,summary,keywords,transcript,todos
最终用于判定的返回
未知的 --artifacts 取值 "x",可选: basic,summary,keywords,transcript,todos
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2608ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test minutes +detail --id DWSREALREADNOSUCHID0000000000000 --artifacts basic --direction older --yes --format json
真实 stdout
{
-  "basic": {
-    "error": "[UNCLASSIFIED] taskUuid is invalid\n  hint: Use --verbose for detailed error logs"
-  },
-  "taskUuid": "DWSREALREADNOSUCHID0000000000000"
-}
真实 stderr / error
(空)
真实返回
{
-  "basic": {
-    "error": "[UNCLASSIFIED] taskUuid is invalid\n  hint: Use --verbose for detailed error logs"
-  },
-  "taskUuid": "DWSREALREADNOSUCHID0000000000000"
-}
dws minutes +latest-minutes --keyword <keyword>read复合/智能型
取我最新的一条妙记(听记)详情
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_minutes_basic_infolark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/list_by_keyword_and_time_range;暂无妙记
输入 / CLI 输出
输入
dws minutes +latest-minutes --keyword x --yes
stdout
(空)
stderr / error
暂无妙记
最终用于判定的返回
暂无妙记
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=8499ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺妙记/听记真实 fixture / 需真实 fixture,非 CLI 可直接修
-当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。
真实输入
/private/tmp/dws-real-test minutes +latest-minutes --keyword '测试' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无妙记"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无妙记"
-  }
-}
dws minutes +list-all --query <query> --limit <limit> --cursor <cursor>read
查询我有权限访问的所有听记列表
dws mcp minutes <tool>lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/list_by_keyword_and_time_range
输入 / CLI 输出
输入
dws minutes +list-all --query x --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "minutes": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "minutes": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=23790ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test minutes +list-all --query '测试' --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 0,
-  "minutes": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "minutes": []
-}
dws minutes +list-mine --query <query> --limit <limit> --cursor <cursor>read
查询我创建的听记列表
dws mcp minutes <tool>lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/list_by_keyword_and_time_range
输入 / CLI 输出
输入
dws minutes +list-mine --query x --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "minutes": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "minutes": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=27516ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test minutes +list-mine --query '测试' --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 0,
-  "minutes": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "minutes": []
-}
dws minutes +list-shared --query <query> --limit <limit> --cursor <cursor>read
查询他人共享给我的听记列表
dws mcp minutes <tool>lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/list_by_keyword_and_time_range
输入 / CLI 输出
输入
dws minutes +list-shared --query x --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "minutes": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "minutes": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1362ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test minutes +list-shared --query '测试' --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 0,
-  "minutes": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "minutes": []
-}
dws minutes +minutes-search --query <query>read复合/智能型
按关键词搜索我的妙记并投影列表
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/list_by_keyword_and_time_range;没搜到妙记
输入 / CLI 输出
输入
dws minutes +minutes-search --query x --yes
stdout
(空)
stderr / error
没搜到妙记
最终用于判定的返回
没搜到妙记
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1150ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test minutes +minutes-search --query '测试' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没搜到妙记"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没搜到妙记"
-  }
-}
dws minutes +record-pause --id <id> --session-id <session-id>write
暂停听记录音
dws mcp minutes 执行听记指令-发起AI听记录音 --json '{"cmd":<..>, "uuid":<..>, "sessionId":<..>}'lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/执行听记指令-发起AI听记录音
输入 / CLI 输出
输入
dws minutes +record-pause --id x --session-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cmd": "pause",
-    "sessionId": "x",
-    "uuid": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cmd": "pause",
-    "sessionId": "x",
-    "uuid": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1233ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test minutes +record-pause --id DWSREALTESTNOSUCHID0000000000000 --session-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "aiAgentTestRunCmdUnknownError",
-    "server_key": "minutes",
-    "trace_id": "0bb7c36217840999713124987e0757"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "aiAgentTestRunCmdUnknownError",
-    "server_key": "minutes",
-    "trace_id": "0bb7c36217840999713124987e0757"
-  }
-}
dws minutes +record-resume --id <id> --session-id <session-id>write
恢复听记录音
dws mcp minutes 执行听记指令-发起AI听记录音 --json '{"cmd":<..>, "uuid":<..>, "sessionId":<..>}'lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/执行听记指令-发起AI听记录音
输入 / CLI 输出
输入
dws minutes +record-resume --id x --session-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cmd": "resume",
-    "sessionId": "x",
-    "uuid": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cmd": "resume",
-    "sessionId": "x",
-    "uuid": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1074ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test minutes +record-resume --id DWSREALTESTNOSUCHID0000000000000 --session-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "aiAgentTestRunCmdUnknownError",
-    "server_key": "minutes",
-    "trace_id": "0bb7c36217840999724452848e0758"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "aiAgentTestRunCmdUnknownError",
-    "server_key": "minutes",
-    "trace_id": "0bb7c36217840999724452848e0758"
-  }
-}
dws minutes +record-start --session-id <session-id>write
发起听记(开始录音)
dws mcp minutes 执行听记指令-发起AI听记录音 --json '{"cmd":<..>, "sessionId":<..>}'lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/执行听记指令-发起AI听记录音
输入 / CLI 输出
输入
dws minutes +record-start --session-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cmd": "create",
-    "sessionId": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cmd": "create",
-    "sessionId": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=821ms;real-backend-safe-negative-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test minutes +record-start --session-id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__ --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "cmd": "create",
-    "operatorUid": 512653609,
-    "sessionId": "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__",
-    "uid": 512653609
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "cmd": "create",
-    "operatorUid": 512653609,
-    "sessionId": "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__",
-    "uid": 512653609
-  },
-  "success": true
-}
dws minutes +record-stop --id <id> --session-id <session-id>write
结束听记录音
dws mcp minutes 执行听记指令-发起AI听记录音 --json '{"cmd":<..>, "uuid":<..>, "sessionId":<..>}'lark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/执行听记指令-发起AI听记录音
输入 / CLI 输出
输入
dws minutes +record-stop --id x --session-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cmd": "end",
-    "sessionId": "x",
-    "uuid": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cmd": "end",
-    "sessionId": "x",
-    "uuid": "x"
-  },
-  "product": "minutes",
-  "tool": "执行听记指令-发起AI听记录音"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1130ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test minutes +record-stop --id DWSREALTESTNOSUCHID0000000000000 --session-id DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "aiAgentTestRunCmdUnknownError",
-    "server_key": "minutes",
-    "trace_id": "2127d89817840999735397508e0757"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "aiAgentTestRunCmdUnknownError",
-    "server_key": "minutes",
-    "trace_id": "2127d89817840999735397508e0757"
-  }
-}
dws minutes +replace-batch --id <id> --pair <pair>write复合/智能型
对一条妙记(听记)批量执行多组文字替换(原文=>替换)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark minutes 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);替换规则 "x" 缺少分隔符 "=>",应为 "原文=>替换"
输入 / CLI 输出
输入
dws minutes +replace-batch --id x --pair x --yes
stdout
(空)
stderr / error
替换规则 "x" 缺少分隔符 "=>",应为 "原文=>替换"
最终用于判定的返回
替换规则 "x" 缺少分隔符 "=>",应为 "原文=>替换"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=941ms;real-backend-safe-negative-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test minutes +replace-batch --id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__ --pair '测试=>测试' --yes --format json
真实 stdout
{
-  "applied": 0,
-  "failed": 1,
-  "results": [
-    {
-      "error": "[UNCLASSIFIED] business error: success=false\n  hint: Use --verbose for detailed error logs",
-      "originalText": "测试",
-      "replacedText": "测试"
-    }
-  ],
-  "taskUuid": "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__",
-  "total": 1
-}
真实 stderr / error
(空)
真实返回
{
-  "applied": 0,
-  "failed": 1,
-  "results": [
-    {
-      "error": "[UNCLASSIFIED] business error: success=false\n  hint: Use --verbose for detailed error logs",
-      "originalText": "测试",
-      "replacedText": "测试"
-    }
-  ],
-  "taskUuid": "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__",
-  "total": 1
-}
dws minutes +transcript --keyword <keyword> --direction <direction>read复合/智能型
取我最新一条妙记(听记)的逐字稿(语音转写原文)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_minutes_transcriptionlark minutes 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;minutes/list_by_keyword_and_time_range;暂无妙记
输入 / CLI 输出
输入
dws minutes +transcript --keyword x --direction x --yes
stdout
(空)
stderr / error
暂无妙记
最终用于判定的返回
暂无妙记
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1096ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
缺妙记/听记真实 fixture / 需真实 fixture,非 CLI 可直接修
-当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。
真实输入
/private/tmp/dws-real-test minutes +transcript --keyword '测试' --direction older --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无妙记"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无妙记"
-  }
-}
-

todo · 13 个 · ↔ lark task

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws todo +assign --to <to> --task <task> --due <due>write复合/智能型
按姓名给某人创建并指派一条待办(自动解析 userId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 create_personal_todolark-cli task +assign已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws todo +assign --to x --task x --due x --yes
stdout
(空)
stderr / error
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1651ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +assign --to '冬翔' --task 'DWS shortcut 真实测试 20260715 assign,可忽略' --due 2026-07-16T10:00:00+08:00 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "result": {
-    "subject": "DWS shortcut 真实测试 20260715 assign,可忽略",
-    "taskId": "55037683708"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "result": {
-    "subject": "DWS shortcut 真实测试 20260715 assign,可忽略",
-    "taskId": "55037683708"
-  },
-  "success": true
-}
dws todo +assign-multi --to <to> --task <task>write复合/智能型
把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 create_personal_todolark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/search_contact_by_key_word;以下执行人姓名无法唯一解析,已中止、未创建任何待办: -x: 通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
输入 / CLI 输出
输入
dws todo +assign-multi --to x --task x --yes
stdout
(空)
stderr / error
以下执行人姓名无法唯一解析,已中止、未创建任何待办:
-x: 通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
最终用于判定的返回
以下执行人姓名无法唯一解析,已中止、未创建任何待办:
-x: 通讯录里没找到叫 "x" 的人;换个更完整的姓名再试。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2445ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +assign-multi --to '冬翔,克谨,怒龙' --task 'DWS shortcut 真实测试 20260715 assign-multi,可忽略' --yes --format json
真实 stdout
{
-  "arguments": [],
-  "result": {
-    "subject": "DWS shortcut 真实测试 20260715 assign-multi,可忽略",
-    "taskId": "55037747621"
-  },
-  "success": true
-}
-{
-  "count": 3,
-  "executors": [
-    "夏东翔(103262)",
-    "黄渊卓(519019)",
-    "刘力(075771)"
-  ],
-  "subject": "DWS shortcut 真实测试 20260715 assign-multi,可忽略"
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "result": {
-    "subject": "DWS shortcut 真实测试 20260715 assign-multi,可忽略",
-    "taskId": "55037747621"
-  },
-  "success": true
-}
-{
-  "count": 3,
-  "executors": [
-    "夏东翔(103262)",
-    "黄渊卓(519019)",
-    "刘力(075771)"
-  ],
-  "subject": "DWS shortcut 真实测试 20260715 assign-multi,可忽略"
-}
dws todo +created-todosread复合/智能型
列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/get_user_todos_in_current_org
输入 / CLI 输出
输入
dws todo +created-todos --yes
stdout
{
-  "created": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "created": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1733ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +created-todos --yes --format json
真实 stdout
{
-  "created": []
-}
真实 stderr / error
(空)
真实返回
{
-  "created": []
-}
dws todo +due-todayread复合/智能型
列出我今天到期的待办
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark task 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);非法的 role-type "x",合法取值:creator, executor, participant
输入 / CLI 输出
输入
dws todo +due-today --role-types x --yes
stdout
(空)
stderr / error
非法的 role-type "x",合法取值:creator, executor, participant
最终用于判定的返回
非法的 role-type "x",合法取值:creator, executor, participant
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1145ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test todo +due-today --role-types executor --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "今天没有到期的待办"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "今天没有到期的待办"
-  }
-}
dws todo +get --task-id <task-id>read
查询待办详情
dws mcp todo get_todo_detail --json '{"taskId":<..>}'lark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/get_todo_detail
输入 / CLI 输出
输入
dws todo +get --task-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "taskId": "x"
-  },
-  "product": "todo",
-  "tool": "get_todo_detail"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "taskId": "x"
-  },
-  "product": "todo",
-  "tool": "get_todo_detail"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3658ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +get --task-id 55119034912 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "todoDetailModel": {
-      "activities": [
-        {
-          "action": "task.self.done",
-          "activityId": "14314572403",
-          "creatorId": 5255888716
-        },
-        {
-          "action": "task.create",
-          "activityId": "14178655650",
-          "creatorId": 2669375904
-        }
-      ],
-      "bizTag": "tb_group_task",
-      "createdTime": 1784017216974,
-      "creatorId": "2669375904",
-      "creatorInfo": {
-        "name": "李龙",
-        "nick": "粤腾",
-        "userId": 2669375904
-      },
-      "detailUrl": {
-        "appUrl": "https://n.dingtalk.com/dingding/dd-todo/detail/index.html?dd_darkmode=true\u0026android_scroll=false\u0026dd_mini_app_id=5000000004746689\u0026dt_editor=true\u0026dd_full_screen=true\u0026showmenu=false\u0026dd_progress=false\u0026dd_amplify=false\u0026newPage=true\u0026at_iframe=1\u0026from=homePage\u0026taskId=55119034912\u0026bizTag=tb_group_task\u0026cid=75985350515#/detail",
-        "pcUrl": "https://n.dingtalk.com/dingding/dd-todo/detail/index.html?dd_darkmode=true\u0026android_scroll=false\u0026dd_mini_app_id=5000000004746689\u0026dt_editor=true\u0026dd_full_screen=true\u0026showmenu=false\u0026dd_progress=false\u0026dd_amplify=false\u0026newPage=true\u0026at_iframe=1\u0026from=homePage\u0026taskId=55119034912\u0026bizTag=tb_group_task\u0026cid=75985350515#/detail"
-      },
-      "dueTime": 1784044740651,
-      "executorIds": [
-        "41129894",
-        "5255888716",
-        "447869",
-        "512653609",
-        "2669375904"
-      ],
-      "executorInfos": [
-        {
-          "name": "梓寻",
-          "nick": "梓寻",
-          "userId": 41129894
-        },
-        {
-          "name": "陈绪",
-          "nick": "陈叙",
-          "userId": 5255888716
-        },
-        {
-          "name": "徐洪鑫",
-          "nick": "崇幻",
-          "userId": 447869
-        },
-        {
-          "name": "董鑫阳",
-          "nick": "念晨",
-          "userId": 512653609
-        },
-        {
-          "name": "李龙",
-          "nick": "粤腾",
-          "userId": 2669375904
-        }
-      ],
-      "finishTime": 0,
-      "isDone": false,
-      "modifiedTime": 1784017216974,
-      "modifierId": "2669375904",
-      "orgId": 439446171,
-      "participantIds": [
-        "41129894",
-        "5255888716",
-        "447869",
-        "512653609",
-        "2669375904"
-      ],
-      "participantInfos": [
-        {
-          "name": "梓寻",
-          "nick": "梓寻",
-          "userId": 41129894
-        },
-        {
-          "name": "陈绪",
-          "nick": "陈叙",
-          "userId": 5255888716
-        },
-        {
-          "name": "徐洪鑫",
-          "nick": "崇幻",
-          "userId": 447869
-        },
-        {
-          "name": "董鑫阳",
-          "nick": "念晨",
-          "userId": 512653609
-        },
-        {
-          "name": "李龙",
-          "nick": "粤腾",
-          "userId": 2669375904
-        }
-      ],
-      "priority": 20,
-      "source": "conversation",
-      "subject": "中控Ai营销各Agent蓝图场景故事动线输出1-2页PPT",
-      "taskId": "55119034912",
-      "tenantId": "75985350515",
-      "tenantType": "group"
-    }
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "todoDetailModel": {
-      "activities": [
-        {
-          "action": "task.self.done",
-          "activityId": "14314572403",
-          "creatorId": 5255888716
-        },
-        {
-          "action": "task.create",
-          "activityId": "14178655650",
-          "creatorId": 2669375904
-        }
-      ],
-      "bizTag": "tb_group_task",
-      "createdTime": 1784017216974,
-      "creatorId": "2669375904",
-      "creatorInfo": {
-        "name": "李龙",
-        "nick": "粤腾",
-        "userId": 2669375904
-      },
-      "detailUrl": {
-        "appUrl": "https://n.dingtalk.com/dingding/dd-todo/detail/index.html?dd_darkmode=true\u0026android_scroll=false\u0026dd_mini_app_id=5000000004746689\u0026dt_editor=true\u0026dd_full_screen=true\u0026showmenu=false\u0026dd_progress=false\u0026dd_amplify=false\u0026newPage=true\u0026at_iframe=1\u0026from=homePage\u0026taskId=55119034912\u0026bizTag=tb_group_task\u0026cid=75985350515#/detail",
-        "pcUrl": "https://n.dingtalk.com/dingding/dd-todo/detail/index.html?dd_darkmode=true\u0026android_scroll=false\u0026dd_mini_app_id=5000000004746689\u0026dt_editor=true\u0026dd_full_screen=true\u0026showmenu=false\u0026dd_progress=false\u0026dd_amplify=false\u0026newPage=true\u0026at_iframe=1\u0026from=homePage\u0026taskId=55119034912\u0026bizTag=tb_group_task\u0026cid=75985350515#/detail"
-      },
-      "dueTime": 1784044740651,
-      "executorIds": [
-        "41129894",
-        "5255888716",
-        "447869",
-        "512653609",
-        "2669375904"
-      ],
-      "executorInfos": [
-        {
-          "name": "梓寻",
-          "nick": "梓寻",
-          "userId": 41129894
-        },
-        {
-          "name": "陈绪",
-          "nick": "陈叙",
-          "userId": 5255888716
-        },
-        {
-          "name": "徐洪鑫",
-          "nick": "崇幻",
-          "userId": 447869
-        },
-        {
-          "name": "董鑫阳",
-          "nick": "念晨",
-          "userId": 512653609
-        },
-        {
-          "name": "李龙",
-          "nick": "粤腾",
-          "userId": 2669375904
-        }
-      ],
-      "finishTime": 0,
-      "isDone": false,
-      "modifiedTime": 1784017216974,
-      "modifierId": "2669375904",
-      "orgId": 439446171,
-      "participantIds": [
-        "41129894",
-        "5255888716",
-        "447869",
-        "512653609",
-        "2669375904"
-      ],
-      "participantInfos": [
-        {
-          "name": "梓寻",
-          "nick": "梓寻",
-          "userId": 41129894
-        },
-        {
-          "name": "陈绪",
-          "nick": "陈叙",
-          "userId": 5255888716
-        },
-        {
-          "name": "徐洪鑫",
-          "nick": "崇幻",
-          "userId": 447869
-        },
-        {
-          "name": "董鑫阳",
-          "nick": "念晨",
-          "userId": 512653609
-        },
-        {
-          "name": "李龙",
-          "nick": "粤腾",
-          "userId": 2669375904
-        }
-      ],
-      "priority": 20,
-      "source": "conversation",
-      "subject": "中控Ai营销各Agent蓝图场景故事动线输出1-2页PPT",
-      "taskId": "55119034912",
-      "tenantId": "75985350515",
-      "tenantType": "group"
-    }
-  },
-  "success": true
-}
dws todo +get-my-tasks --page <page> --size <size> --status <status> --priority <priority> --role-types <role-types> --plan-finish-start <plan-finish-start> --plan-finish-end <plan-finish-end>read
查询当前组织下我的待办列表
dws mcp todo <tool>lark-cli task +get-my-tasks已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/get_user_todos_in_current_org
输入 / CLI 输出
输入
dws todo +get-my-tasks --page x --size x --status true --priority x --role-types x --plan-finish-start 1 --plan-finish-end 1 --yes
stdout
{
-  "count": 0,
-  "todos": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "todos": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1475ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +get-my-tasks --page 1 --size 10 --status false --priority x --role-types executor --plan-finish-start 1 --plan-finish-end 1 --yes --format json
真实 stdout
{
-  "count": 0,
-  "todos": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "todos": []
-}
dws todo +list-attachment --task-id <task-id>read
查询待办任务的附件列表
dws mcp todo <tool>lark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/list_todo_attachment
输入 / CLI 输出
输入
dws todo +list-attachment --task-id x --yes
stdout
{
-  "attachments": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "attachments": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3413ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +list-attachment --task-id 55119034912 --yes --format json
真实 stdout
{
-  "attachments": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "attachments": [],
-  "count": 0
-}
dws todo +list-comment --task-id <task-id> --page <page> --size <size>read
查询待办评论列表
dws mcp todo <tool>lark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/list_todo_comment
输入 / CLI 输出
输入
dws todo +list-comment --task-id x --page x --size x --yes
stdout
{
-  "comments": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "comments": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2191ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +list-comment --task-id 55119034912 --page 1 --size 10 --yes --format json
真实 stdout
{
-  "comments": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "comments": [],
-  "count": 0
-}
dws todo +list-sub --task-id <task-id>read
查询子待办列表
dws mcp todo <tool>lark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/list_sub_tasks
输入 / CLI 输出
输入
dws todo +list-sub --task-id x --yes
stdout
{
-  "count": 0,
-  "subTasks": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "subTasks": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1940ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +list-sub --task-id 55119034912 --yes --format json
真实 stdout
{
-  "count": 0,
-  "subTasks": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "subTasks": []
-}
dws todo +overdueread复合/智能型
列出我已过期未完成的待办
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/get_user_todos_in_current_org
输入 / CLI 输出
输入
dws todo +overdue --yes
stdout
{
-  "overdue": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "overdue": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1240ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test todo +overdue --yes --format json
真实 stdout
{
-  "overdue": []
-}
真实 stderr / error
(空)
真实返回
{
-  "overdue": []
-}
dws todo +related-tasksread复合/智能型
一次性列出与我相关的全部待办(我作为创建人/执行人/参与人三种角色的并集,按 taskId 去重)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark task 无同名命令(能力/命名不同)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);非法的 role-type "x",合法取值:creator, executor, participant
输入 / CLI 输出
输入
dws todo +related-tasks --role-types x --status x --yes
stdout
(空)
stderr / error
非法的 role-type "x",合法取值:creator, executor, participant
最终用于判定的返回
非法的 role-type "x",合法取值:creator, executor, participant
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1122ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test todo +related-tasks --role-types executor --status false --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没有与你相关的待办(creator/executor/participant 三种角色下均为空)"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没有与你相关的待办(creator/executor/participant 三种角色下均为空)"
-  }
-}
dws todo +remind --task <task> --at <at>write复合/智能型
给自己创建一条带截止/提醒时间的待办
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 create_personal_todolark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;contact/get_current_user_profile;无法解析当前登录用户的 userId,无法给自己创建待办
输入 / CLI 输出
输入
dws todo +remind --task x --at x --yes
stdout
(空)
stderr / error
无法解析当前登录用户的 userId,无法给自己创建待办
最终用于判定的返回
无法解析当前登录用户的 userId,无法给自己创建待办
真实后端测试输入 / 输出真实后端成功
exit=0;duration=4240ms;real-backend-manual-write; no --mock; no --dry-run; cleanup: taskId 55042039022 deleted successfully
真实输入
/private/tmp/dws-real-test todo +remind --task 'DWS shortcut 修复复测 20260715 remind,可忽略' --at 2026-07-16T10:00:00+08:00 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "result": {
-    "subject": "DWS shortcut 修复复测 20260715 remind,可忽略",
-    "taskId": "55042039022"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "result": {
-    "subject": "DWS shortcut 修复复测 20260715 remind,可忽略",
-    "taskId": "55042039022"
-  },
-  "success": true
-}
dws todo +todo-done --task <task>write复合/智能型
按标题关键词把我的某条待办标记完成(自动定位 taskId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 update_todo_done_statuslark task 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;todo/get_user_todos_in_current_org;没找到匹配待办:标题里没有 "x" 的待办。
输入 / CLI 输出
输入
dws todo +todo-done --task x --yes
stdout
(空)
stderr / error
没找到匹配待办:标题里没有 "x" 的待办。
最终用于判定的返回
没找到匹配待办:标题里没有 "x" 的待办。
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1771ms;real-backend-safe-negative-write; no --mock; no --dry-run
前置真实资源准备
为 todo +todo-done 创建当前账号自己的临时待办 fixture
-input: /private/tmp/dws-real-test todo +remind --task 'DWS shortcut 真实测试待办 20260715-160449' --at 2026-07-22T10:15:00+08:00 --yes --format json
-exit=0;duration=2611ms
-stdout:
-{
-  "arguments": [],
-  "result": {
-    "subject": "DWS shortcut 真实测试待办 20260715-160449",
-    "taskId": "55055639461"
-  },
-  "success": true
-}
-stderr:
-(空)
真实输入
/private/tmp/dws-real-test todo +todo-done --task 'DWS shortcut 真实测试待办 20260715-160449' --yes --format json
真实 stdout
{
-  "result": {
-    "success": true
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "result": {
-    "success": true
-  },
-  "success": true
-}
-

mail · 10 个 · ↔ lark mail

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws mail +contact-list --email <email> --limit <limit> --cursor <cursor>read
列出指定邮箱的所有邮件联系人
dws mcp mail <tool>lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/list_user_mail_contacts
输入 / CLI 输出
输入
dws mail +contact-list --email x --limit x --cursor x --yes
stdout
{
-  "contacts": [],
-  "count": 0
-}
stderr / error
(空)
最终用于判定的返回
{
-  "contacts": [],
-  "count": 0
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3284ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +contact-list --email xinyang.dxy@alibaba-inc.com --limit 10 --cursor '' --yes --format json
真实 stdout
{
-  "contacts": [],
-  "count": 0
-}
真实 stderr / error
(空)
真实返回
{
-  "contacts": [],
-  "count": 0
-}
dws mail +find-mail-user --query <query> --limit <limit>read复合/智能型
按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/search_mail_users;没搜到邮箱联系人
输入 / CLI 输出
输入
dws mail +find-mail-user --query x --limit 1 --yes
stdout
(空)
stderr / error
没搜到邮箱联系人
最终用于判定的返回
没搜到邮箱联系人
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3666ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +find-mail-user --query '董鑫阳' --limit 10 --yes --format json
真实 stdout
{
-  "count": 1,
-  "users": [
-    {
-      "email": "xinyang.dxy@alibaba-inc.com",
-      "employeeNo": "202397",
-      "id": "----.l------oBfPl:0:----HI-----HWk9nI",
-      "jobTitle": "",
-      "name": "董鑫阳",
-      "nickname": "念晨",
-      "workLocation": "浙江-杭州"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 1,
-  "users": [
-    {
-      "email": "xinyang.dxy@alibaba-inc.com",
-      "employeeNo": "202397",
-      "id": "----.l------oBfPl:0:----HI-----HWk9nI",
-      "jobTitle": "",
-      "name": "董鑫阳",
-      "nickname": "念晨",
-      "workLocation": "浙江-杭州"
-    }
-  ]
-}
dws mail +folder-list --email <email> --folder <folder>read
列出顶层文件夹或指定父文件夹下的子文件夹
dws mcp mail <tool>lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/list_folders
输入 / CLI 输出
输入
dws mail +folder-list --email x --folder x --yes
stdout
{
-  "count": 0,
-  "folders": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "folders": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3643ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +folder-list --email xinyang.dxy@alibaba-inc.com --folder 2 --yes --format json
真实 stdout
{
-  "count": 0,
-  "folders": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "folders": []
-}
dws mail +recent-mail --limit <limit> --email <email> --folder <folder>read复合/智能型
列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/list_mailbox_threads;最近没有邮件
输入 / CLI 输出
输入
dws mail +recent-mail --limit 1 --email x --folder x --yes
stdout
(空)
stderr / error
最近没有邮件
最终用于判定的返回
最近没有邮件
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2724ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +recent-mail --limit 10 --email xinyang.dxy@alibaba-inc.com --folder 2 --yes --format json
真实 stdout
{
-  "count": 10,
-  "mails": [
-    {
-      "date": "2026-07-15T06:37:28Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "牛康佳(随风)的数据权限申请流程 / NIU, Kangjia's apply data permission  ",
-      "threadId": "DzzzzyJeBJL---.iLmnRnm"
-    },
-    {
-      "date": "2026-07-15T06:35:49Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "changefree:diamond应用【albert-agent-2】变更 ",
-      "threadId": "DzzzzyJo3ZK---.hyYf9dO"
-    },
-    {
-      "date": "2026-07-15T06:00:39Z",
-      "from": [
-        "安全服务中心 <snotice@alibaba-inc.com>"
-      ],
-      "subject": "您的名下有1个风险尚未修复,请立即修复! You have 1 vulnerabilities that have not been fixed, please fix them as soon as possible!",
-      "threadId": "DzzzzyKaAwA---.g643XXa"
-    },
-    {
-      "date": "2026-07-15T05:48:41Z",
-      "from": [
-        "aone@alibaba-inc.com <aone@alibaba-inc.com>"
-      ],
-      "subject": "[Aone]应用albert-agent-2预发发布成功",
-      "threadId": "DzzzzyJi7OS---.iCFAI0A"
-    },
-    {
-      "date": "2026-07-15T02:54:00Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "代成俊(璟琦)的数据权限申请流程 / DAI, Chengjun's apply data permission  ",
-      "threadId": "DzzzzyKTIMD---.gOEBekI"
-    },
-    {
-      "date": "2026-07-15T02:51:12Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "陈梦媛(星莫)的数据权限申请流程 / CHEN, Mengyuan's apply data permission  ",
-      "threadId": "DzzzzyKIHke---.goV93L."
-    },
-    {
-      "date": "2026-07-15T02:06:21Z",
-      "from": [
-        "dingtalk_recruitment@alibaba-inc.com"
-      ],
-      "subject": "【校招】简历即将超期/[Campus Recruitment] Resumes Are About to Expire",
-      "threadId": "DzzzzyJjBhI---.i911RPE"
-    },
-    {
-      "date": "2026-07-15T02:00:05Z",
-      "from": [
-        "dingtalk_recruitment@alibaba-inc.com"
-      ],
-      "subject": "【校招】2026-07-15 面试邀约任务通知/【Campus Recruitment】 2026-07-15 Interview Invitation Task Notice",
-      "threadId": "DzzzzyJeFNO---.iLVkwOD"
-    },
-    {
-      "date": "2026-07-15T01:42:44Z",
-      "from": [
-        "ATA技小蜜 <ATA@alibaba-inc.com>"
-      ],
-      "subject": "【ATA每日精选】【天猫数字分身】(上篇) - AI Native 组织协作与研发的暗物质",
-      "threadId": "DzzzzyJeFdf---.iLWDlI4"
-    },
-    {
-      "date": "2026-07-14T18:24:45Z",
-      "from": [
-        "postmaster@alibaba-inc.com"
-      ],
-      "subject": "垃圾邮件提醒2026年7月15日",
-      "threadId": "DzzzzyJeM2G---.iLDMDRq"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 10,
-  "mails": [
-    {
-      "date": "2026-07-15T06:37:28Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "牛康佳(随风)的数据权限申请流程 / NIU, Kangjia's apply data permission  ",
-      "threadId": "DzzzzyJeBJL---.iLmnRnm"
-    },
-    {
-      "date": "2026-07-15T06:35:49Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "changefree:diamond应用【albert-agent-2】变更 ",
-      "threadId": "DzzzzyJo3ZK---.hyYf9dO"
-    },
-    {
-      "date": "2026-07-15T06:00:39Z",
-      "from": [
-        "安全服务中心 <snotice@alibaba-inc.com>"
-      ],
-      "subject": "您的名下有1个风险尚未修复,请立即修复! You have 1 vulnerabilities that have not been fixed, please fix them as soon as possible!",
-      "threadId": "DzzzzyKaAwA---.g643XXa"
-    },
-    {
-      "date": "2026-07-15T05:48:41Z",
-      "from": [
-        "aone@alibaba-inc.com <aone@alibaba-inc.com>"
-      ],
-      "subject": "[Aone]应用albert-agent-2预发发布成功",
-      "threadId": "DzzzzyJi7OS---.iCFAI0A"
-    },
-    {
-      "date": "2026-07-15T02:54:00Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "代成俊(璟琦)的数据权限申请流程 / DAI, Chengjun's apply data permission  ",
-      "threadId": "DzzzzyKTIMD---.gOEBekI"
-    },
-    {
-      "date": "2026-07-15T02:51:12Z",
-      "from": [
-        "work@alibaba-inc.com"
-      ],
-      "subject": "陈梦媛(星莫)的数据权限申请流程 / CHEN, Mengyuan's apply data permission  ",
-      "threadId": "DzzzzyKIHke---.goV93L."
-    },
-    {
-      "date": "2026-07-15T02:06:21Z",
-      "from": [
-        "dingtalk_recruitment@alibaba-inc.com"
-      ],
-      "subject": "【校招】简历即将超期/[Campus Recruitment] Resumes Are About to Expire",
-      "threadId": "DzzzzyJjBhI---.i911RPE"
-    },
-    {
-      "date": "2026-07-15T02:00:05Z",
-      "from": [
-        "dingtalk_recruitment@alibaba-inc.com"
-      ],
-      "subject": "【校招】2026-07-15 面试邀约任务通知/【Campus Recruitment】 2026-07-15 Interview Invitation Task Notice",
-      "threadId": "DzzzzyJeFNO---.iLVkwOD"
-    },
-    {
-      "date": "2026-07-15T01:42:44Z",
-      "from": [
-        "ATA技小蜜 <ATA@alibaba-inc.com>"
-      ],
-      "subject": "【ATA每日精选】【天猫数字分身】(上篇) - AI Native 组织协作与研发的暗物质",
-      "threadId": "DzzzzyJeFdf---.iLWDlI4"
-    },
-    {
-      "date": "2026-07-14T18:24:45Z",
-      "from": [
-        "postmaster@alibaba-inc.com"
-      ],
-      "subject": "垃圾邮件提醒2026年7月15日",
-      "threadId": "DzzzzyJeM2G---.iLDMDRq"
-    }
-  ]
-}
dws mail +search-mail --query <query> --email <email> --size <size>read复合/智能型
按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/search_emails
输入 / CLI 输出
输入
dws mail +search-mail --query x --email x --size x --yes
stdout
{
-  "email": "x",
-  "messages": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "email": "x",
-  "messages": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1985ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +search-mail --query 'subject:测试' --email xinyang.dxy@alibaba-inc.com --size 10 --yes --format json
真实 stdout
{
-  "email": "xinyang.dxy@alibaba-inc.com",
-  "messages": [
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNklS",
-      "subject": "[Aone]实验室[lippi-calladmin单元测试]的构建[#1723]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkml",
-      "subject": "[Aone]实验室[mozi-engine-admin单元测试-Jacoco]的构建[#2611]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNknb",
-      "subject": "[Aone]实验室[lippi-calladmin单元测试]的构建[#1646]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkoy",
-      "subject": "[Aone]实验室[mozi-engine-admin单元测试-Jacoco]的构建[#2609]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkp.",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1498]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkp0",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1497]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkpJ",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1493]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkpL",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1492]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkph",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1491]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkpj",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1490]失败"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "email": "xinyang.dxy@alibaba-inc.com",
-  "messages": [
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNklS",
-      "subject": "[Aone]实验室[lippi-calladmin单元测试]的构建[#1723]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkml",
-      "subject": "[Aone]实验室[mozi-engine-admin单元测试-Jacoco]的构建[#2611]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNknb",
-      "subject": "[Aone]实验室[lippi-calladmin单元测试]的构建[#1646]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkoy",
-      "subject": "[Aone]实验室[mozi-engine-admin单元测试-Jacoco]的构建[#2609]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkp.",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1498]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkp0",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1497]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkpJ",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1493]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkpL",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1492]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkph",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1491]失败"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkpj",
-      "subject": "[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1490]失败"
-    }
-  ]
-}
dws mail +tag-list --email <email>read
列出指定邮箱下的所有邮件标签
dws mcp mail <tool>lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/list_tags
输入 / CLI 输出
输入
dws mail +tag-list --email x --yes
stdout
{
-  "count": 0,
-  "tags": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "tags": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2606ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +tag-list --email xinyang.dxy@alibaba-inc.com --yes --format json
真实 stdout
{
-  "count": 3,
-  "tags": [
-    {
-      "id": "1",
-      "name": "Flagged",
-      "parentId": "0"
-    },
-    {
-      "id": "2",
-      "name": "Completed",
-      "parentId": "0"
-    },
-    {
-      "id": "3",
-      "name": "task",
-      "parentId": "0"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 3,
-  "tags": [
-    {
-      "id": "1",
-      "name": "Flagged",
-      "parentId": "0"
-    },
-    {
-      "id": "2",
-      "name": "Completed",
-      "parentId": "0"
-    },
-    {
-      "id": "3",
-      "name": "task",
-      "parentId": "0"
-    }
-  ]
-}
dws mail +template-list --email <email> --limit <limit> --cursor <cursor>read
列出指定邮箱的所有邮件模板
dws mcp mail <tool>lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/list_user_message_templates
输入 / CLI 输出
输入
dws mail +template-list --email x --limit x --cursor x --yes
stdout
{
-  "count": 0,
-  "templates": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "templates": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3083ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +template-list --email xinyang.dxy@alibaba-inc.com --limit 10 --cursor '' --yes --format json
真实 stdout
{
-  "count": 0,
-  "templates": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "templates": []
-}
dws mail +thread-list --email <email> --folder <folder> --limit <limit> --cursor <cursor> --start <start> --end <end> --ascending <ascending>read
列出指定邮箱文件夹下的邮件会话(thread)
dws mcp mail <tool>lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/list_mailbox_threads
输入 / CLI 输出
输入
dws mail +thread-list --email x --folder x --limit 1 --cursor x --start x --end x --ascending --yes
stdout
{
-  "count": 0,
-  "threads": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "threads": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=6434ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +thread-list --email xinyang.dxy@alibaba-inc.com --folder 2 --limit 10 --cursor '' --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --ascending --yes --format json
真实 stdout
{
-  "count": 0,
-  "threads": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "threads": []
-}
dws mail +unread-mail --email <email> --size <size>read复合/智能型
列出未读邮件并投影列表(主题/发件人/时间/messageId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/search_emails
输入 / CLI 输出
输入
dws mail +unread-mail --email x --size x --yes
stdout
{
-  "email": "x",
-  "messages": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "email": "x",
-  "messages": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3469ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +unread-mail --email xinyang.dxy@alibaba-inc.com --size 10 --yes --format json
真实 stdout
{
-  "email": "xinyang.dxy@alibaba-inc.com",
-  "messages": [
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk0",
-      "subject": "牛康佳(随风)的数据权限申请流程 / NIU, Kangjia's apply data permission  "
-    },
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk1",
-      "subject": "changefree:diamond应用【albert-agent-2】变更 "
-    },
-    {
-      "date": null,
-      "from": "安全服务中心 <snotice@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk2",
-      "subject": "您的名下有1个风险尚未修复,请立即修复! You have 1 vulnerabilities that have not been fixed, please fix them as soon as possible!"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk3",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk4",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk5",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk6",
-      "subject": "代成俊(璟琦)的数据权限申请流程 / DAI, Chengjun's apply data permission  "
-    },
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk7",
-      "subject": "陈梦媛(星莫)的数据权限申请流程 / CHEN, Mengyuan's apply data permission  "
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk8",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "dingtalk_recruitment@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk9",
-      "subject": "【校招】简历即将超期/[Campus Recruitment] Resumes Are About to Expire"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "email": "xinyang.dxy@alibaba-inc.com",
-  "messages": [
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk0",
-      "subject": "牛康佳(随风)的数据权限申请流程 / NIU, Kangjia's apply data permission  "
-    },
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk1",
-      "subject": "changefree:diamond应用【albert-agent-2】变更 "
-    },
-    {
-      "date": null,
-      "from": "安全服务中心 <snotice@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk2",
-      "subject": "您的名下有1个风险尚未修复,请立即修复! You have 1 vulnerabilities that have not been fixed, please fix them as soon as possible!"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk3",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk4",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk5",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk6",
-      "subject": "代成俊(璟琦)的数据权限申请流程 / DAI, Chengjun's apply data permission  "
-    },
-    {
-      "date": null,
-      "from": "work@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk7",
-      "subject": "陈梦媛(星莫)的数据权限申请流程 / CHEN, Mengyuan's apply data permission  "
-    },
-    {
-      "date": null,
-      "from": "aone@alibaba-inc.com <aone@alibaba-inc.com>",
-      "messageId": "DzzzzzzNkk8",
-      "subject": "[Aone]应用albert-agent-2预发发布成功"
-    },
-    {
-      "date": null,
-      "from": "dingtalk_recruitment@alibaba-inc.com",
-      "messageId": "DzzzzzzNkk9",
-      "subject": "【校招】简历即将超期/[Campus Recruitment] Resumes Are About to Expire"
-    }
-  ]
-}
dws mail +user-search --keyword <keyword> --employee-no <employee-no> --email <email> --cursor <cursor> --limit <limit>read
按关键词或工号搜索邮箱用户(仅企业邮箱)
dws mcp mail <tool>lark mail 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;mail/search_mail_users
输入 / CLI 输出
输入
dws mail +user-search --keyword x --employee-no x --email x --cursor x --limit x --yes
stdout
{
-  "count": 0,
-  "users": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "users": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2836ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test mail +user-search --keyword '董鑫阳' --employee-no 202397 --email xinyang.dxy@alibaba-inc.com --cursor '' --limit 10 --yes --format json
真实 stdout
{
-  "count": 1,
-  "users": [
-    {
-      "email": "xinyang.dxy@alibaba-inc.com",
-      "employeeNo": "202397",
-      "name": "董鑫阳",
-      "userId": "----.l------oBfPl:0:----HI-----HWk9nI"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 1,
-  "users": [
-    {
-      "email": "xinyang.dxy@alibaba-inc.com",
-      "employeeNo": "202397",
-      "name": "董鑫阳",
-      "userId": "----.l------oBfPl:0:----HI-----HWk9nI"
-    }
-  ]
-}
-

oa · 10 个 · 钉钉特有 · lark 无对应

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws oa +approve-by --keyword <keyword> --comment <comment>write复合/智能型
按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 approve_processInstance无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/list_pending_approvals;没找到待审批单据:待我处理的审批里没有标题/单号包含 "x" 的单据。
输入 / CLI 输出
输入
dws oa +approve-by --keyword x --comment x --yes
stdout
(空)
stderr / error
没找到待审批单据:待我处理的审批里没有标题/单号包含 "x" 的单据。
最终用于判定的返回
没找到待审批单据:待我处理的审批里没有标题/单号包含 "x" 的单据。
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1318ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test oa +approve-by --keyword __DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__ --comment 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没找到待审批单据:待我处理的审批里没有标题/单号包含 \"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__\" 的单据。"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没找到待审批单据:待我处理的审批里没有标题/单号包含 \"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__\" 的单据。"
-  }
-}
dws oa +done-approvals --limit <limit>read复合/智能型
只读列出我已处理过的审批任务(审批历史)并投影为可读列表
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/get_done_tasks;没有已处理的审批记录
输入 / CLI 输出
输入
dws oa +done-approvals --limit 1 --yes
stdout
(空)
stderr / error
没有已处理的审批记录
最终用于判定的返回
没有已处理的审批记录
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1244ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test oa +done-approvals --limit 10 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没有已处理的审批记录"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没有已处理的审批记录"
-  }
-}
dws oa +list-cc --page <page> --limit <limit> --query <query>read
获取抄送当前用户的审批单列表
dws mcp oa <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/get_noticed_instances
输入 / CLI 输出
输入
dws oa +list-cc --page x --limit x --query x --yes
stdout
{
-  "count": 0,
-  "instances": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "instances": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=8468ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test oa +list-cc --page 1 --limit 10 --query '测试' --yes --format json
真实 stdout
{
-  "count": 0,
-  "instances": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "instances": []
-}
dws oa +list-executed --page <page> --limit <limit> --query <query>read
获取当前用户已经处理过的审批单列表
dws mcp oa <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/get_done_tasks
输入 / CLI 输出
输入
dws oa +list-executed --page x --limit x --query x --yes
stdout
{
-  "count": 0,
-  "instances": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "instances": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=29589ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test oa +list-executed --page 1 --limit 10 --query '测试' --yes --format json
真实 stdout
{
-  "count": 0,
-  "instances": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "instances": []
-}
dws oa +list-forms --cursor <cursor> --limit <limit>read
获取当前用户可见的审批表单列表
dws mcp oa <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/list_user_visible_process
输入 / CLI 输出
输入
dws oa +list-forms --cursor 1 --limit 1 --yes
stdout
{
-  "count": 0,
-  "forms": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "forms": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=25611ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test oa +list-forms --cursor 0 --limit 10 --yes --format json
真实 stdout
{
-  "count": 0,
-  "forms": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "forms": []
-}
dws oa +list-pending --start <start> --end <end> --page <page> --limit <limit> --query <query>read
查询待我处理的审批(时间范围为 epoch 毫秒)
dws mcp oa <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/list_pending_approvals
输入 / CLI 输出
输入
dws oa +list-pending --start 1 --end 1 --page x --limit x --query x --yes
stdout
{
-  "count": 0,
-  "instances": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "instances": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=8328ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test oa +list-pending --start 1783494851348 --end 1784099651348 --page 1 --limit 10 --query '测试' --yes --format json
真实 stdout
{
-  "count": 0,
-  "instances": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "instances": []
-}
dws oa +list-submitted --page <page> --limit <limit> --query <query>read
获取当前用户已发起的审批单列表
dws mcp oa <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/get_submitted_instances
输入 / CLI 输出
输入
dws oa +list-submitted --page x --limit x --query x --yes
stdout
{
-  "count": 0,
-  "instances": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "instances": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=12793ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test oa +list-submitted --page 1 --limit 10 --query '测试' --yes --format json
真实 stdout
{
-  "count": 0,
-  "instances": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "instances": []
-}
dws oa +my-initiated --query <query> --page <page> --limit <limit>read复合/智能型
列出我发起(提交)的审批单据
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/get_submitted_instances
输入 / CLI 输出
输入
dws oa +my-initiated --query x --page 1 --limit 1 --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "pageNumber": 1,
-    "pageSize": 1,
-    "query": "x"
-  },
-  "product": "oa",
-  "tool": "get_submitted_instances"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "pageNumber": 1,
-    "pageSize": 1,
-    "query": "x"
-  },
-  "product": "oa",
-  "tool": "get_submitted_instances"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3564ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test oa +my-initiated --query '测试' --page 1 --limit 10 --yes --format json
真实 stdout
{
-  "result": {
-    "values": []
-  },
-  "success": "true"
-}
真实 stderr / error
(空)
真实返回
{
-  "result": {
-    "values": []
-  },
-  "success": "true"
-}
dws oa +pending --limit <limit>read复合/智能型
只读列出待我审批的审批任务并投影为可读列表(只看不批)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/list_pending_approvals;当前没有待我审批的任务
输入 / CLI 输出
输入
dws oa +pending --limit 1 --yes
stdout
(空)
stderr / error
当前没有待我审批的任务
最终用于判定的返回
当前没有待我审批的任务
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1357ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test oa +pending --limit 10 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "当前没有待我审批的任务"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "当前没有待我审批的任务"
-  }
-}
dws oa +search-forms --query <query>read
按关键字模糊搜索当前用户可见的审批表单
dws mcp oa <tool>无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;oa/search_form
输入 / CLI 输出
输入
dws oa +search-forms --query x --yes
stdout
{
-  "count": 0,
-  "forms": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "forms": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1759ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test oa +search-forms --query '测试' --yes --format json
真实 stdout
{
-  "count": 0,
-  "forms": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "forms": []
-}
-

drive · 9 个 · ↔ lark drive

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws drive +copy --node <node> --folder <folder> --workspace <workspace>write
复制文件/文档到指定位置
dws mcp doc copy_document --json '{"nodeId":<..>, "targetFolderId":<..>, "workspaceId":<..>}'lark drive 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/copy_document
输入 / CLI 输出
输入
dws drive +copy --node x --folder x --workspace x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "copy_document"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "copy_document"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1329ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test drive +copy --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json
真实 stdout
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-  "message": "节点复制成功,已复制到目标文件夹。sourceNodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ,新节点 ID: np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-  "nodeId": "np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-  "message": "节点复制成功,已复制到目标文件夹。sourceNodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ,新节点 ID: np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-  "nodeId": "np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-  "success": true
-}
dws drive +download --node <node> --space-id <space-id>read
获取钉盘文件下载链接
dws mcp drive download_file --json '{"fileId":<..>, "spaceId":<..>}'lark-cli drive +download已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;drive/download_file
输入 / CLI 输出
输入
dws drive +download --node x --space-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "fileId": "x",
-    "spaceId": "x"
-  },
-  "product": "drive",
-  "tool": "download_file"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "fileId": "x",
-    "spaceId": "x"
-  },
-  "product": "drive",
-  "tool": "download_file"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1192ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test drive +download --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --space-id 1 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.document.typeIllegal",
-    "server_key": "drive",
-    "trace_id": "0bab027317840997956923965e08c9"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.document.typeIllegal",
-    "server_key": "drive",
-    "trace_id": "0bab027317840997956923965e08c9"
-  }
-}
dws drive +find-file --query <query>read复合/智能型
按名称关键词搜索钉盘文件并投影关键字段(只读)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark drive 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;drive/search_files
输入 / CLI 输出
输入
dws drive +find-file --query x --yes
stdout
{
-  "files": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "files": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1913ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test drive +find-file --query '测试' --yes --format json
真实 stdout
{
-  "files": [
-    {
-      "dentryId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "fileSize": 0,
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "fileSize": 0,
-      "name": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "fileSize": 0,
-      "name": "文件扫描测试 -20260414",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "fileSize": 0,
-      "name": "文件扫描测试(25)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "fileSize": 0,
-      "name": "文件扫描测试(5)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "fileSize": 0,
-      "name": "文件扫描测试(24)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "fileSize": 0,
-      "name": "文件扫描测试(22)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "fileSize": 0,
-      "name": "文件扫描测试(26)(2)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-      "fileSize": 0,
-      "name": "DWS shortcut 真实测试文档 20260715(2)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-      "fileSize": 0,
-      "name": "DWS shortcut 真实测试文档 20260715(1)",
-      "type": "ALIDOC"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "files": [
-    {
-      "dentryId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "fileSize": 0,
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "fileSize": 0,
-      "name": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "fileSize": 0,
-      "name": "文件扫描测试 -20260414",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "fileSize": 0,
-      "name": "文件扫描测试(25)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "fileSize": 0,
-      "name": "文件扫描测试(5)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "fileSize": 0,
-      "name": "文件扫描测试(24)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "fileSize": 0,
-      "name": "文件扫描测试(22)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "fileSize": 0,
-      "name": "文件扫描测试(26)(2)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l",
-      "fileSize": 0,
-      "name": "DWS shortcut 真实测试文档 20260715(2)",
-      "type": "ALIDOC"
-    },
-    {
-      "dentryId": "7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3",
-      "fileSize": 0,
-      "name": "DWS shortcut 真实测试文档 20260715(1)",
-      "type": "ALIDOC"
-    }
-  ]
-}
dws drive +info --node <node> --space-id <space-id>read
获取钉盘文件/文件夹元数据
dws mcp drive get_file_info --json '{"fileId":<..>, "spaceId":<..>}'lark drive 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;drive/get_file_info
输入 / CLI 输出
输入
dws drive +info --node x --space-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "fileId": "x",
-    "spaceId": "x"
-  },
-  "product": "drive",
-  "tool": "get_file_info"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "fileId": "x",
-    "spaceId": "x"
-  },
-  "product": "drive",
-  "tool": "get_file_info"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2605ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test drive +info --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --space-id 1 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "createTime": 1784082107000,
-    "dentryId": "228840832141",
-    "docUrl": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-    "extension": "adoc",
-    "fileId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-    "fileSize": 0,
-    "message": "该文件属于钉钉文档(在线文档/表格/脑图等),通过钉盘接口获取的元数据(如文件名称)可能不准确。建议使用钉钉文档相关接口获取更准确的文档信息。",
-    "modifyTime": 1784082137000,
-    "name": "7691256802.adoc",
-    "path": "/6342122716/7691256801/7691256802.adoc",
-    "status": "NORMAL",
-    "type": "ALIDOC"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "createTime": 1784082107000,
-    "dentryId": "228840832141",
-    "docUrl": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-    "extension": "adoc",
-    "fileId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-    "fileSize": 0,
-    "message": "该文件属于钉钉文档(在线文档/表格/脑图等),通过钉盘接口获取的元数据(如文件名称)可能不准确。建议使用钉钉文档相关接口获取更准确的文档信息。",
-    "modifyTime": 1784082137000,
-    "name": "7691256802.adoc",
-    "path": "/6342122716/7691256801/7691256802.adoc",
-    "status": "NORMAL",
-    "type": "ALIDOC"
-  },
-  "success": true
-}
dws drive +list --space-id <space-id> --folder <folder> --limit <limit> --cursor <cursor> --order-by <order-by> --order <order> --thumbnail <thumbnail>read
列出钉盘文件/文件夹
dws mcp drive <tool>lark drive 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;drive/list_files
输入 / CLI 输出
输入
dws drive +list --space-id x --folder x --limit 1 --cursor x --order-by x --order x --thumbnail --yes
stdout
{
-  "count": 0,
-  "files": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "files": []
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1090ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test drive +list --space-id 1 --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --limit 10 --cursor 0 --order-by name --order asc --thumbnail --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "paramError",
-    "server_key": "drive",
-    "trace_id": "2106d98117840997968627612e087b"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "paramError",
-    "server_key": "drive",
-    "trace_id": "2106d98117840997968627612e087b"
-  }
-}
dws drive +move --node <node> --folder <folder> --workspace <workspace>write
移动文件/文档到指定位置
dws mcp doc move_document --json '{"nodeId":<..>, "targetFolderId":<..>, "workspaceId":<..>}'lark-cli drive +move已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/move_document
输入 / CLI 输出
输入
dws drive +move --node x --folder x --workspace x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "move_document"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "move_document"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=3380ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test drive +move --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json
真实 stdout
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "message": "节点移动成功,已移动到目标文件夹。nodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ",
-  "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "docUrl": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "message": "节点移动成功,已移动到目标文件夹。nodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ",
-  "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-  "success": true
-}
dws drive +recent --operate-type <operate-type> --creator-type <creator-type> --limit <limit> --cursor <cursor>read
获取最近访问/编辑的文档列表
dws mcp doc <tool>lark drive 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/get_recent_list
输入 / CLI 输出
输入
dws drive +recent --operate-type 1 --creator-type 1 --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "items": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "items": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2087ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test drive +recent --operate-type 1 --creator-type 1 --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 0,
-  "hasMore": false,
-  "items": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "hasMore": false,
-  "items": []
-}
dws drive +search --query <query> --target <target> --file-types <file-types> --extensions <extensions> --creator-uids <creator-uids> --created-from <created-from> --created-to <created-to> --modified-from <modified-from> --modified-to <modified-to> --limit <limit> --cursor <cursor>read
搜索钉盘文件
dws mcp drive <tool>lark-cli drive +search已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;drive/search_files
输入 / CLI 输出
输入
dws drive +search --query x --target file --file-types x --extensions x --creator-uids x --created-from 1 --created-to 1 --modified-from 1 --modified-to 1 --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "files": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "files": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2091ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test drive +search --query '测试' --target file --file-types alidoc --extensions x --creator-uids x --created-from 1 --created-to 1 --modified-from 1 --modified-to 1 --limit 10 --cursor 0 --yes --format json
真实 stdout
{
-  "count": 0,
-  "files": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "files": []
-}
dws drive +search-docs --query <query> --limit <limit>read
搜索文档空间文档
dws mcp doc <tool>lark drive 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/search_documents
输入 / CLI 输出
输入
dws drive +search-docs --query x --limit 1 --yes
stdout
{
-  "count": 0,
-  "docs": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "docs": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2507ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test drive +search-docs --query '测试' --limit 10 --yes --format json
真实 stdout
{
-  "count": 10,
-  "docs": [
-    {
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "name": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "nodeId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试 -20260414",
-      "nodeId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(25)",
-      "nodeId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(5)",
-      "nodeId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(24)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(22)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(26)(2)",
-      "nodeId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space"
-    },
-    {
-      "name": "悟空DWS全部测试账号信息",
-      "nodeId": "X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space"
-    },
-    {
-      "name": "测试",
-      "nodeId": "1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE",
-      "type": "folder",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 10,
-  "docs": [
-    {
-      "name": "DWS shortcut 真实测试文档 20260715",
-      "nodeId": "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space"
-    },
-    {
-      "name": "🧪测试 工作区目录扫描 - 2026-04-27 21:27:03",
-      "nodeId": "yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试 -20260414",
-      "nodeId": "3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(25)",
-      "nodeId": "QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(5)",
-      "nodeId": "Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(24)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(22)",
-      "nodeId": "OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space"
-    },
-    {
-      "name": "文件扫描测试(26)(2)",
-      "nodeId": "l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space"
-    },
-    {
-      "name": "悟空DWS全部测试账号信息",
-      "nodeId": "X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ",
-      "type": "file",
-      "url": "https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space"
-    },
-    {
-      "name": "测试",
-      "nodeId": "1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE",
-      "type": "folder",
-      "url": "https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space"
-    }
-  ]
-}
-

wiki · 7 个 · ↔ lark wiki

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws wiki +node-copy --workspace <workspace> --node <node> --folder <folder>write
复制知识库节点
dws mcp doc copy_document --json '{"nodeId":<..>, "workspaceId":<..>, "targetFolderId":<..>}'lark-cli wiki +node-copy已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/copy_document
输入 / CLI 输出
输入
dws wiki +node-copy --workspace x --node x --folder x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "copy_document"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "copy_document"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1000ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test wiki +node-copy --workspace DWSREALTESTNOSUCHID0000000000000 --node DWSREALTESTNOSUCHID0000000000000 --folder DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document)\n  hint: Document may have been deleted or moved",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc",
-    "trace_id": "0bb7c36217840999769818495e0779"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document)\n  hint: Document may have been deleted or moved",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc",
-    "trace_id": "0bb7c36217840999769818495e0779"
-  }
-}
dws wiki +node-list --workspace <workspace> --folder <folder> --limit <limit> --cursor <cursor>read
列出知识库节点
dws mcp doc <tool>lark-cli wiki +node-list已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;wiki/list_nodes
输入 / CLI 输出
输入
dws wiki +node-list --workspace x --folder x --limit 1 --cursor x --yes
stdout
{
-  "count": 0,
-  "nodes": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "nodes": []
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1120ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test wiki +node-list --workspace DWSREALREADNOSUCHID0000000000000 --folder DWSREALREADNOSUCHID0000000000000 --limit 10 --cursor 0 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "wiki",
-    "technical_detail": "PARAM_ERROR - 未找到指定工具",
-    "trace_id": "2132f5ca17840998189126304e08d9"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] business error: success=false\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "wiki",
-    "technical_detail": "PARAM_ERROR - 未找到指定工具",
-    "trace_id": "2132f5ca17840998189126304e08d9"
-  }
-}
dws wiki +node-move --workspace <workspace> --node <node> --folder <folder>write
移动知识库节点
dws mcp doc move_document --json '{"nodeId":<..>, "workspaceId":<..>, "targetFolderId":<..>}'lark wiki 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;doc/move_document
输入 / CLI 输出
输入
dws wiki +node-move --workspace x --node x --folder x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "move_document"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "targetFolderId": "x",
-    "workspaceId": "x"
-  },
-  "product": "doc",
-  "tool": "move_document"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1053ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test wiki +node-move --workspace DWSREALTESTNOSUCHID0000000000000 --node DWSREALTESTNOSUCHID0000000000000 --folder DWSREALTESTNOSUCHID0000000000000 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document)\n  hint: Document may have been deleted or moved",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc",
-    "trace_id": "0bb7c36217840999780286509e07fe"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document)\n  hint: Document may have been deleted or moved",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "invalidRequest.inputArgs.invalid",
-    "server_key": "doc",
-    "trace_id": "0bb7c36217840999780286509e07fe"
-  }
-}
dws wiki +resolve-space --name <name>read复合/智能型
按名称搜索知识空间并解析出唯一 spaceId(只读)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark wiki 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;wiki/search_wikiSpaces;没有找到名称包含 x 的知识空间
输入 / CLI 输出
输入
dws wiki +resolve-space --name x --yes
stdout
(空)
stderr / error
没有找到名称包含 x 的知识空间
最终用于判定的返回
没有找到名称包含 x 的知识空间
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1533ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test wiki +resolve-space --name 'DWS shortcut 真实测试' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没有找到名称包含 DWS shortcut 真实测试 的知识空间"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没有找到名称包含 DWS shortcut 真实测试 的知识空间"
-  }
-}
dws wiki +space-list --type <type> --limit <limit> --cursor <cursor>read
列出组织 / 个人知识库
dws mcp wiki <tool>lark-cli wiki +space-list已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;wiki/list_wikiSpaces
输入 / CLI 输出
输入
dws wiki +space-list --type orgWikiSpace --limit x --cursor x --yes
stdout
{
-  "count": 0,
-  "spaces": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "spaces": []
-}
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=759ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test wiki +space-list --type ALL --limit 10 --cursor 0 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "参数 --type 取值 \"ALL\" 不合法,允许值:orgWikiSpace, myWikiSpace"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "参数 --type 取值 \"ALL\" 不合法,允许值:orgWikiSpace, myWikiSpace"
-  }
-}
dws wiki +space-search --query <query> --limit <limit>read
搜索知识库
dws mcp wiki <tool>lark wiki 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;wiki/search_wikiSpaces
输入 / CLI 输出
输入
dws wiki +space-search --query x --limit x --yes
stdout
{
-  "count": 0,
-  "spaces": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "spaces": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1951ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test wiki +space-search --query '测试' --limit 10 --yes --format json
真实 stdout
{
-  "count": 0,
-  "spaces": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "spaces": []
-}
dws wiki +wiki-new-doc --space <space> --title <title>write复合/智能型
在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚lark wiki 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;wiki/search_wikiSpaces;没找到名为 "x" 的知识库;换个更完整/精确的空间名再试。
输入 / CLI 输出
输入
dws wiki +wiki-new-doc --space x --title x --yes
stdout
(空)
stderr / error
没找到名为 "x" 的知识库;换个更完整/精确的空间名再试。
最终用于判定的返回
没找到名为 "x" 的知识库;换个更完整/精确的空间名再试。
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1152ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
缺真实资源 fixture / 需真实 fixture,非 CLI 可直接修
-真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。
真实输入
/private/tmp/dws-real-test wiki +wiki-new-doc --space __DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__ --title 'DWS shortcut 真实测试 20260715-151724' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没找到名为 \"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__\" 的知识库;换个更完整/精确的空间名再试。"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "没找到名为 \"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__\" 的知识库;换个更完整/精确的空间名再试。"
-  }
-}
-

ding · 5 个 · 钉钉特有 · lark 无对应

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws ding +list --cursor <cursor> --type <type>read
查询 DING 消息列表
dws mcp im list_ding_messages --json '{"type":<..>, "cursor":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_ding_messages
输入 / CLI 输出
输入
dws ding +list --cursor 1 --type x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "cursor": 1,
-    "type": "x"
-  },
-  "product": "im",
-  "tool": "list_ding_messages"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "cursor": 1,
-    "type": "x"
-  },
-  "product": "im",
-  "tool": "list_ding_messages"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1459ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test ding +list --cursor 0 --type ALL --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "dingMessages": [
-      {
-        "openDingId": "5D73E3AC29C780072D1CD56C6874ACC2",
-        "sendTime": "2026-07-14 20:30:06",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "6DE6EC00AEBEF6839CA5F309371550DA",
-        "sendTime": "2026-07-10 16:17:20",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "4DE0B0D90A94683CE1A9E12CBBB82883",
-        "sendTime": "2026-07-10 12:38:47",
-        "senderNick": "思敏"
-      },
-      {
-        "openDingId": "B1CF538F2A3CAD8F78E863BCFF10E90F",
-        "sendTime": "2026-07-10 11:47:07",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "72D79962A868374366AF4FCA6946D49F",
-        "sendTime": "2026-07-10 11:44:27",
-        "senderNick": "汀葻"
-      },
-      {
-        "openDingId": "A290A8F6D35CC8E712E3E4C30D915263",
-        "sendTime": "2026-07-10 11:10:52",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "7B7BA69E68F85A3948B70A1BD79C9DF2",
-        "sendTime": "2026-07-09 20:43:36",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "0D59FF6F871900140BD65422184C410C",
-        "sendTime": "2026-07-09 17:25:36",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "B6072FCC82D85A343EE7834A70293F09",
-        "sendTime": "2026-07-09 16:37:54",
-        "senderNick": "王玮(哈瑞)"
-      },
-      {
-        "openDingId": "91DFBF0D54B7A0DCC8F7EAF58560586C",
-        "sendTime": "2026-07-09 15:17:19",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "EA1F2A52C13954855A3D0D7A32A973F6",
-        "sendTime": "2026-07-09 11:52:15",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "340C8FD58735886D44B5EA46921F8AAD",
-        "sendTime": "2026-07-07 11:48:58",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "A389AD2799303C13C15BB6EF014D3B39",
-        "sendTime": "2026-07-07 11:34:34",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "A26385411682C6C5042D775B25DA8F08",
-        "sendTime": "2026-07-06 11:37:05",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "C42A8B86D5453197F79645460E0B7A99",
-        "sendTime": "2026-07-03 17:41:43",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "5AEF5D3CF756F23345F86FF5E7544E73",
-        "sendTime": "2026-07-03 15:40:52",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "EA0E39C793A943797EEDEA9B00740A72",
-        "sendTime": "2026-07-03 14:40:23",
-        "senderNick": "粤腾"
-      },
-      {
-        "openDingId": "753EC85C1FA6E50D82EB5C19940F340E",
-        "sendTime": "2026-07-02 20:26:38",
-        "senderNick": "思敏"
-      },
-      {
-        "openDingId": "782B702452A410EC6D30ADC9D4E95FDB",
-        "sendTime": "2026-07-02 09:49:45",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "983C2C5A65CAE1A86D8C6BC272E73D11",
-        "sendTime": "2026-07-02 09:48:09",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "40B84F9DF18DE299C2B2D712E7D01114",
-        "sendTime": "2026-06-29 17:59:37",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "F799B63D8EDDB7915C80159BD355704F",
-        "sendTime": "2026-06-25 19:53:17",
-        "senderNick": "文靖"
-      },
-      {
-        "openDingId": "33C1238264ACA098634569396F60EE7A",
-        "sendTime": "2026-06-25 18:10:19",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "2A05EE1E37F7EB023C3146819156E9A8",
-        "sendTime": "2026-06-25 11:49:24",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "29FBFA46D6F07ED25378F2871E24A1DC",
-        "sendTime": "2026-06-24 12:00:32",
-        "senderNick": "云欢"
-      },
-      {
-        "openDingId": "2CE4C4F3EE8C332884FA1673C064921C",
-        "sendTime": "2026-06-22 20:47:14",
-        "senderNick": "黎常"
-      },
-      {
-        "openDingId": "8A2B1A1F7E508B2F08A52FEDD9365843",
-        "sendTime": "2026-06-18 13:41:19",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "7F7671C7853D88C92DE6EE0A671CB57B",
-        "sendTime": "2026-06-17 10:52:34",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "9539E8D27A51F204197E326F94650279",
-        "sendTime": "2026-06-16 11:50:18",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "DE9E8FAFC8125BC715B999AC4267E2CA",
-        "sendTime": "2026-06-15 21:35:40",
-        "senderNick": "西渐(主用钉)"
-      },
-      {
-        "openDingId": "A5D83E673DC5A97F38D95F3E135C7824",
-        "sendTime": "2026-06-12 22:08:36",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "5360613B1FC2389E8435CFABC57CC012",
-        "sendTime": "2026-06-12 13:51:21",
-        "senderNick": "鱼非"
-      },
-      {
-        "openDingId": "3AB6395324A62CB2B4B513D0A02F67FA",
-        "sendTime": "2026-06-11 11:23:39",
-        "senderNick": "徐潇扬"
-      },
-      {
-        "openDingId": "FB9FB2808D6ADF7464F5F1ACBA8A6704",
-        "sendTime": "2026-06-11 09:51:55",
-        "senderNick": "徐潇扬"
-      },
-      {
-        "openDingId": "4D094E702716D4A46015CDC12B8F216B",
-        "sendTime": "2026-06-10 22:04:05",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "74E2B3EB36EE99E6D88744F6B2C4C265",
-        "sendTime": "2026-06-10 18:14:48",
-        "senderNick": "古榕"
-      },
-      {
-        "openDingId": "CAEF5614C47B65CDF10AED44BDFC7D9F",
-        "sendTime": "2026-06-10 16:02:42",
-        "senderNick": "徐潇扬"
-      },
-      {
-        "openDingId": "F2C3A0F51AC502E84AECB932C227AAED",
-        "sendTime": "2026-06-09 17:43:20",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "8AB2C3B891D931D8D650D41CA6205BCE",
-        "sendTime": "2026-06-09 15:50:23",
-        "senderNick": "可菡"
-      },
-      {
-        "openDingId": "0456CE6A50FD2902B68D772DA37A96BC",
-        "sendTime": "2026-06-09 09:45:43",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "6CB0E9E1D2CC344CB9217C97550BC6C6",
-        "sendTime": "2026-06-08 21:42:37",
-        "senderNick": "林弈"
-      },
-      {
-        "openDingId": "0DEFB0942003A059C230BE1D262CAC8C",
-        "sendTime": "2026-06-08 21:27:39",
-        "senderNick": "林弈"
-      },
-      {
-        "openDingId": "6B600C709E7468700F208B5DBB7ED888",
-        "sendTime": "2026-06-08 15:42:05",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "B3502CC153C09A27AADE63A0BDF94C4A",
-        "sendTime": "2026-06-05 17:38:44",
-        "senderNick": "慧德"
-      },
-      {
-        "openDingId": "07EC05F3E019605304D3C8216C6F7A32",
-        "sendTime": "2026-06-05 17:19:52",
-        "senderNick": "弈宇(Alpha)"
-      },
-      {
-        "openDingId": "1C25016699ADE6DE66C6C9ECAE8C83B2",
-        "sendTime": "2026-06-05 14:46:54",
-        "senderNick": "弈宇(Alpha)"
-      },
-      {
-        "openDingId": "AF79C95530607985A95866F7DE45F45E",
-        "sendTime": "2026-06-05 14:39:48",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "4BFEE560E26AA24C1966A4D6EB722B89",
-        "sendTime": "2026-06-05 09:28:42",
-        "senderNick": "慧德"
-      },
-      {
-        "openDingId": "B7F170045D3C6CC9D37DE356276BF084",
-        "sendTime": "2026-06-04 23:07:57",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "A04A3D9659BD8FBDCF0C5E28EB2314F7",
-        "sendTime": "2026-06-04 17:30:21",
-        "senderNick": "念晨"
-      }
-    ],
-    "hasMore": true,
-    "nextCursor": 1780565411433
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "dingMessages": [
-      {
-        "openDingId": "5D73E3AC29C780072D1CD56C6874ACC2",
-        "sendTime": "2026-07-14 20:30:06",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "6DE6EC00AEBEF6839CA5F309371550DA",
-        "sendTime": "2026-07-10 16:17:20",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "4DE0B0D90A94683CE1A9E12CBBB82883",
-        "sendTime": "2026-07-10 12:38:47",
-        "senderNick": "思敏"
-      },
-      {
-        "openDingId": "B1CF538F2A3CAD8F78E863BCFF10E90F",
-        "sendTime": "2026-07-10 11:47:07",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "72D79962A868374366AF4FCA6946D49F",
-        "sendTime": "2026-07-10 11:44:27",
-        "senderNick": "汀葻"
-      },
-      {
-        "openDingId": "A290A8F6D35CC8E712E3E4C30D915263",
-        "sendTime": "2026-07-10 11:10:52",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "7B7BA69E68F85A3948B70A1BD79C9DF2",
-        "sendTime": "2026-07-09 20:43:36",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "0D59FF6F871900140BD65422184C410C",
-        "sendTime": "2026-07-09 17:25:36",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "B6072FCC82D85A343EE7834A70293F09",
-        "sendTime": "2026-07-09 16:37:54",
-        "senderNick": "王玮(哈瑞)"
-      },
-      {
-        "openDingId": "91DFBF0D54B7A0DCC8F7EAF58560586C",
-        "sendTime": "2026-07-09 15:17:19",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "EA1F2A52C13954855A3D0D7A32A973F6",
-        "sendTime": "2026-07-09 11:52:15",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "340C8FD58735886D44B5EA46921F8AAD",
-        "sendTime": "2026-07-07 11:48:58",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "A389AD2799303C13C15BB6EF014D3B39",
-        "sendTime": "2026-07-07 11:34:34",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "A26385411682C6C5042D775B25DA8F08",
-        "sendTime": "2026-07-06 11:37:05",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "C42A8B86D5453197F79645460E0B7A99",
-        "sendTime": "2026-07-03 17:41:43",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "5AEF5D3CF756F23345F86FF5E7544E73",
-        "sendTime": "2026-07-03 15:40:52",
-        "senderNick": "楠者"
-      },
-      {
-        "openDingId": "EA0E39C793A943797EEDEA9B00740A72",
-        "sendTime": "2026-07-03 14:40:23",
-        "senderNick": "粤腾"
-      },
-      {
-        "openDingId": "753EC85C1FA6E50D82EB5C19940F340E",
-        "sendTime": "2026-07-02 20:26:38",
-        "senderNick": "思敏"
-      },
-      {
-        "openDingId": "782B702452A410EC6D30ADC9D4E95FDB",
-        "sendTime": "2026-07-02 09:49:45",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "983C2C5A65CAE1A86D8C6BC272E73D11",
-        "sendTime": "2026-07-02 09:48:09",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "40B84F9DF18DE299C2B2D712E7D01114",
-        "sendTime": "2026-06-29 17:59:37",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "F799B63D8EDDB7915C80159BD355704F",
-        "sendTime": "2026-06-25 19:53:17",
-        "senderNick": "文靖"
-      },
-      {
-        "openDingId": "33C1238264ACA098634569396F60EE7A",
-        "sendTime": "2026-06-25 18:10:19",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "2A05EE1E37F7EB023C3146819156E9A8",
-        "sendTime": "2026-06-25 11:49:24",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "29FBFA46D6F07ED25378F2871E24A1DC",
-        "sendTime": "2026-06-24 12:00:32",
-        "senderNick": "云欢"
-      },
-      {
-        "openDingId": "2CE4C4F3EE8C332884FA1673C064921C",
-        "sendTime": "2026-06-22 20:47:14",
-        "senderNick": "黎常"
-      },
-      {
-        "openDingId": "8A2B1A1F7E508B2F08A52FEDD9365843",
-        "sendTime": "2026-06-18 13:41:19",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "7F7671C7853D88C92DE6EE0A671CB57B",
-        "sendTime": "2026-06-17 10:52:34",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "9539E8D27A51F204197E326F94650279",
-        "sendTime": "2026-06-16 11:50:18",
-        "senderNick": "穆伦"
-      },
-      {
-        "openDingId": "DE9E8FAFC8125BC715B999AC4267E2CA",
-        "sendTime": "2026-06-15 21:35:40",
-        "senderNick": "西渐(主用钉)"
-      },
-      {
-        "openDingId": "A5D83E673DC5A97F38D95F3E135C7824",
-        "sendTime": "2026-06-12 22:08:36",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "5360613B1FC2389E8435CFABC57CC012",
-        "sendTime": "2026-06-12 13:51:21",
-        "senderNick": "鱼非"
-      },
-      {
-        "openDingId": "3AB6395324A62CB2B4B513D0A02F67FA",
-        "sendTime": "2026-06-11 11:23:39",
-        "senderNick": "徐潇扬"
-      },
-      {
-        "openDingId": "FB9FB2808D6ADF7464F5F1ACBA8A6704",
-        "sendTime": "2026-06-11 09:51:55",
-        "senderNick": "徐潇扬"
-      },
-      {
-        "openDingId": "4D094E702716D4A46015CDC12B8F216B",
-        "sendTime": "2026-06-10 22:04:05",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "74E2B3EB36EE99E6D88744F6B2C4C265",
-        "sendTime": "2026-06-10 18:14:48",
-        "senderNick": "古榕"
-      },
-      {
-        "openDingId": "CAEF5614C47B65CDF10AED44BDFC7D9F",
-        "sendTime": "2026-06-10 16:02:42",
-        "senderNick": "徐潇扬"
-      },
-      {
-        "openDingId": "F2C3A0F51AC502E84AECB932C227AAED",
-        "sendTime": "2026-06-09 17:43:20",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "8AB2C3B891D931D8D650D41CA6205BCE",
-        "sendTime": "2026-06-09 15:50:23",
-        "senderNick": "可菡"
-      },
-      {
-        "openDingId": "0456CE6A50FD2902B68D772DA37A96BC",
-        "sendTime": "2026-06-09 09:45:43",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "6CB0E9E1D2CC344CB9217C97550BC6C6",
-        "sendTime": "2026-06-08 21:42:37",
-        "senderNick": "林弈"
-      },
-      {
-        "openDingId": "0DEFB0942003A059C230BE1D262CAC8C",
-        "sendTime": "2026-06-08 21:27:39",
-        "senderNick": "林弈"
-      },
-      {
-        "openDingId": "6B600C709E7468700F208B5DBB7ED888",
-        "sendTime": "2026-06-08 15:42:05",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "B3502CC153C09A27AADE63A0BDF94C4A",
-        "sendTime": "2026-06-05 17:38:44",
-        "senderNick": "慧德"
-      },
-      {
-        "openDingId": "07EC05F3E019605304D3C8216C6F7A32",
-        "sendTime": "2026-06-05 17:19:52",
-        "senderNick": "弈宇(Alpha)"
-      },
-      {
-        "openDingId": "1C25016699ADE6DE66C6C9ECAE8C83B2",
-        "sendTime": "2026-06-05 14:46:54",
-        "senderNick": "弈宇(Alpha)"
-      },
-      {
-        "openDingId": "AF79C95530607985A95866F7DE45F45E",
-        "sendTime": "2026-06-05 14:39:48",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "4BFEE560E26AA24C1966A4D6EB722B89",
-        "sendTime": "2026-06-05 09:28:42",
-        "senderNick": "慧德"
-      },
-      {
-        "openDingId": "B7F170045D3C6CC9D37DE356276BF084",
-        "sendTime": "2026-06-04 23:07:57",
-        "senderNick": "念晨"
-      },
-      {
-        "openDingId": "A04A3D9659BD8FBDCF0C5E28EB2314F7",
-        "sendTime": "2026-06-04 17:30:21",
-        "senderNick": "念晨"
-      }
-    ],
-    "hasMore": true,
-    "nextCursor": 1780565411433
-  },
-  "success": true
-}
dws ding +recall-personal --id <id>high-risk-write
撤回本人发起的 DING
dws mcp im recall_personal_ding --json '{"openDingId":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/recall_personal_ding
输入 / CLI 输出
输入
dws ding +recall-personal --id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openDingId": "x"
-  },
-  "product": "im",
-  "tool": "recall_personal_ding"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openDingId": "x"
-  },
-  "product": "im",
-  "tool": "recall_personal_ding"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1415ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test ding +recall-personal --id 8508193BEA49427AA9FFE94EB9D6B28E --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {},
-  "success": true
-}
dws ding +receiver-status --ding-id <ding-id>read
查询 DING 消息接收人已读状态
dws mcp im list_ding_receiver_status --json '{"openDingId":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/list_ding_receiver_status
输入 / CLI 输出
输入
dws ding +receiver-status --ding-id x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openDingId": "x"
-  },
-  "product": "im",
-  "tool": "list_ding_receiver_status"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openDingId": "x"
-  },
-  "product": "im",
-  "tool": "list_ding_receiver_status"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1823ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test ding +receiver-status --ding-id 5D73E3AC29C780072D1CD56C6874ACC2 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "receivers": [
-      {
-        "confirmedStatus": 1,
-        "openDingId": "5D73E3AC29C780072D1CD56C6874ACC2",
-        "receiverNick": "粤腾"
-      }
-    ]
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "receivers": [
-      {
-        "confirmedStatus": 1,
-        "openDingId": "5D73E3AC29C780072D1CD56C6874ACC2",
-        "receiverNick": "粤腾"
-      }
-    ]
-  },
-  "success": true
-}
dws ding +send-by-message --group <group> --message-id <message-id> --users <users> --type <type> --uuid <uuid>write
针对某条消息发起 DING 提醒
dws mcp im send_ding_by_message --json '{"openConversationId":<..>, "openMessageId":<..>, "receiverOpenDingTalkIds":<..>, "remindType":<..>, "uuid":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/send_ding_by_message
输入 / CLI 输出
输入
dws ding +send-by-message --group x --message-id x --users x --type x --uuid x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x",
-    "receiverOpenDingTalkIds": [
-      "x"
-    ],
-    "remindType": "x",
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "send_ding_by_message"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "openConversationId": "x",
-    "openMessageId": "x",
-    "receiverOpenDingTalkIds": [
-      "x"
-    ],
-    "remindType": "x",
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "send_ding_by_message"
-}
真实后端测试输入 / 输出真实后端返回错误
exit=1;duration=1058ms;real-backend-safe-negative-write; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test ding +send-by-message --group cidDWSREALTESTNOSUCHCONV --message-id DWSREALTESTNOSUCHID0000000000000 --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --type FIXED --uuid dws-shortcut-real-negative-20260715-151724 --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999678466213e0853"
-  }
-}
真实返回
{
-  "error": {
-    "category": "api",
-    "code": 1,
-    "hint": "The API returned a business-level error. Check required parameters and values.",
-    "message": "[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message)\n  hint: Use --verbose for detailed error logs",
-    "operation": "tools/call",
-    "reason": "business_error",
-    "server_error_code": "PARAM_ERROR",
-    "server_key": "im",
-    "trace_id": "0b5deb3217840999678466213e0853"
-  }
-}
dws ding +send-personal --users <users> --content <content> --type <type> --uuid <uuid>write
以本人身份发送 DING 给指定人
dws mcp im send_personal_ding --json '{"receiverOpenDingTalkIds":<..>, "content":<..>, "remindType":<..>, "uuid":<..>}'无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;im/send_personal_ding
输入 / CLI 输出
输入
dws ding +send-personal --users x --content x --type x --uuid x --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "content": "x",
-    "receiverOpenDingTalkIds": [
-      "x"
-    ],
-    "remindType": "x",
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "send_personal_ding"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "content": "x",
-    "receiverOpenDingTalkIds": [
-      "x"
-    ],
-    "remindType": "x",
-    "uuid": "x"
-  },
-  "product": "im",
-  "tool": "send_personal_ding"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1338ms;real-backend-manual-write; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test ding +send-personal --users 103262 --content 'DWS shortcut 真实测试 20260715:验证 ding +send-personal,请忽略。' --type app --uuid dws-shortcut-ding-20260715-001 --yes --format json
真实 stdout
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openDingId": "8508193BEA49427AA9FFE94EB9D6B28E"
-  },
-  "success": true
-}
真实 stderr / error
(空)
真实返回
{
-  "arguments": [],
-  "errorCode": null,
-  "errorMsg": null,
-  "result": {
-    "openDingId": "8508193BEA49427AA9FFE94EB9D6B28E"
-  },
-  "success": true
-}
-

report · 3 个 · 钉钉特有 · lark 无对应

- - - - - - - - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws report +inbox-list --start <start> --end <end> --cursor <cursor> --size <size> --sender-user-ids <sender-user-ids>read
列出我收到的日报(按时间范围分页)
dws mcp report <tool>无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--start 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): parsing time "x" as "2006-01-02T15:04:05Z07:00": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws report +inbox-list --start x --end x --cursor 1 --size 1 --sender-user-ids x --yes
stdout
(空)
stderr / error
--start 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): parsing time "x" as "2006-01-02T15:04:05Z07:00": cannot parse "x" as "2006"
最终用于判定的返回
--start 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): parsing time "x" as "2006-01-02T15:04:05Z07:00": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=2130ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test report +inbox-list --start 2026-07-08T15:14:39+08:00 --end 2026-07-15T15:14:39+08:00 --cursor 0 --size 1 --sender-user-ids x --yes --format json
真实 stdout
{
-  "count": 0,
-  "reports": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "reports": []
-}
dws report +outbox-list --cursor <cursor> --size <size> --start <start> --end <end> --modified-start <modified-start> --modified-end <modified-end> --template-name <template-name>read
列出我发出的日报(可选时间/模版名过滤)
dws mcp report <tool>无对应(钉钉特有服务)实测校验拦截
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 0;tool 已校验;无 MCP 调用(校验前拦截);--start 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): parsing time "x" as "2006-01-02T15:04:05Z07:00": cannot parse "x" as "2006"
输入 / CLI 输出
输入
dws report +outbox-list --cursor 1 --size 1 --start x --end x --modified-start x --modified-end x --template-name x --yes
stdout
(空)
stderr / error
--start 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): parsing time "x" as "2006-01-02T15:04:05Z07:00": cannot parse "x" as "2006"
最终用于判定的返回
--start 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): parsing time "x" as "2006-01-02T15:04:05Z07:00": cannot parse "x" as "2006"
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1584ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test report +outbox-list --cursor 0 --size 1 --start 2026-07-08T15:14:41+08:00 --end 2026-07-15T15:14:41+08:00 --modified-start 2026-07-08T15:14:41+08:00 --modified-end 2026-07-15T15:14:41+08:00 --yes --format json
真实 stdout
{
-  "count": 0,
-  "reports": []
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 0,
-  "reports": []
-}
dws report +report-latest --keyword <keyword>read复合/智能型
取我最新提交的一篇日志/汇报详情(钉钉原生)
复合编排:多次 MCP 调用 / 分页、消歧、聚合或回滚;入口工具 get_report_entry_details无对应(钉钉特有服务)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;report/get_send_report_list;暂无日志
输入 / CLI 输出
输入
dws report +report-latest --keyword x --yes
stdout
(空)
stderr / error
暂无日志
最终用于判定的返回
暂无日志
真实后端测试输入 / 输出真实后端返回错误
exit=3;duration=1259ms;real-backend-read; no --mock; no --dry-run
失败归因 / 可修复性
输入或业务校验失败 / 需区分测试输入或后端规则
-命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。
真实输入
/private/tmp/dws-real-test report +report-latest --keyword '测试' --yes --format json
真实 stdout
(空)
真实 stderr / error
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无日志"
-  }
-}
真实返回
{
-  "error": {
-    "category": "validation",
-    "code": 3,
-    "message": "暂无日志"
-  }
-}
-

sheet · 2 个 · ↔ lark sheets

- - - - - - - - - - - - - - -
dws shortcut(新增)原生 MCP / 编排说明lark-cli 对应实际测试情况
dws sheet +list-sheets --node <node>read
获取表格文档中全部工作表列表
dws mcp sheet <tool>lark sheets 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;sheet/get_all_sheets
输入 / CLI 输出
输入
dws sheet +list-sheets --node x --yes
stdout
{
-  "count": 0,
-  "sheets": []
-}
stderr / error
(空)
最终用于判定的返回
{
-  "count": 0,
-  "sheets": []
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1690ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test sheet +list-sheets --node mweZ92PV6O36dZbnsMZx70ylJxEKBD6p --yes --format json
真实 stdout
{
-  "count": 1,
-  "sheets": [
-    {
-      "sheetId": "kgqie6hm",
-      "title": "Sheet1"
-    }
-  ]
-}
真实 stderr / error
(空)
真实返回
{
-  "count": 1,
-  "sheets": [
-    {
-      "sheetId": "kgqie6hm",
-      "title": "Sheet1"
-    }
-  ]
-}
dws sheet +read --node <node> --sheet-id <sheet-id> --range <range> --value-render-option <value-render-option>read
读取工作表指定范围的结构化单元格数据
dws mcp sheet get_cell_infos --json '{"nodeId":<..>, "sheetId":<..>, "range":<..>, "valueRenderOption":<..>}'lark sheets 无同名命令(能力/命名不同)已实测装配
cobra 实际执行 + fake MCP 拦截,零网络/零副作用;调用数 1;tool 已校验;sheet/get_cell_infos
输入 / CLI 输出
输入
dws sheet +read --node x --sheet-id x --range x --value-render-option formatted_value --yes
stdout
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "range": "x",
-    "sheetId": "x",
-    "valueRenderOption": "formatted_value"
-  },
-  "product": "sheet",
-  "tool": "get_cell_infos"
-}
stderr / error
(空)
最终用于判定的返回
{
-  "_fake": true,
-  "args": {
-    "nodeId": "x",
-    "range": "x",
-    "sheetId": "x",
-    "valueRenderOption": "formatted_value"
-  },
-  "product": "sheet",
-  "tool": "get_cell_infos"
-}
真实后端测试输入 / 输出真实后端成功
exit=0;duration=1589ms;real-backend-read; no --mock; no --dry-run
真实输入
/private/tmp/dws-real-test sheet +read --node DWSREALREADNOSUCHID0000000000000 --sheet-id x --range x --value-render-option formatted_value --yes --format json
真实 stdout
null
真实 stderr / error
(空)
真实返回
null
-
由 scripts/gen_shortcut_comparison.py 从源码自动生成 · 数据随 shortcut 演进刷新
\ No newline at end of file diff --git a/docs/shortcut-real-read-results.json b/docs/shortcut-real-read-results.json deleted file mode 100644 index 78bc5f82..00000000 --- a/docs/shortcut-real-read-results.json +++ /dev/null @@ -1,4974 +0,0 @@ -{ - "generated_at": "2026-07-15T16:13:24.234893", - "summary": { - "total": 204, - "ok": 162, - "error": 41, - "timeout": 0, - "held": 1 - }, - "failure_categories": { - "input-or-business-validation": 16, - "backend-or-mcp-error": 9, - "missing-real-resource": 7, - "auth-or-permission": 6, - "missing-real-minutes-fixture": 3, - "held": 1 - }, - "results": [ - { - "service": "aitable", - "command": "+base-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +base-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json", - "args": [ - "aitable", - "+base-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"baseName\": \"Real共创版设备去向登记\",\n \"dashboards\": [\n {\n \"chartCount\": 8,\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\",\n \"dashboardName\": \"仪表盘\",\n \"meta\": {}\n }\n ],\n \"documents\": [],\n \"tables\": [\n {\n \"tableId\": \"hERWDMS\",\n \"tableName\": \"Mac Mini\"\n },\n {\n \"tableId\": \"k8d898q\",\n \"tableName\": \"手机\"\n },\n {\n \"tableId\": \"zyLRwwa\",\n \"tableName\": \"SIM卡\"\n },\n {\n \"tableId\": \"L53jNZz\",\n \"tableName\": \"机箱\"\n },\n {\n \"tableId\": \"zTiHNDL\",\n \"tableName\": \"磁吸片\"\n },\n {\n \"tableId\": \"anMdihs\",\n \"tableName\": \"手机线\"\n }\n ]\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Got base 'Real共创版设备去向登记' with 6 table(s). URL rules: AI 表格访问地址: https://docs.dingtalk.com/i/nodes/{baseId}; AI 表格数据表访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}; AI 表格仪表盘访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{dashboardId}; AI 表格说明文档地址: https://pre-alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{documentId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1773 - }, - { - "service": "aitable", - "command": "+base-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +base-list --limit 10 --cursor 0 --yes --format json", - "args": [ - "aitable", - "+base-list", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"bases\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1289 - }, - { - "service": "aitable", - "command": "+base-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +base-search --query '测试' --cursor 0 --yes --format json", - "args": [ - "aitable", - "+base-search", - "--query", - "测试", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"bases\": [\n {\n \"baseId\": \"yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-27 21:27:03\"\n },\n {\n \"baseId\": \"3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm\",\n \"baseName\": \"文件扫描测试 -20260414\"\n },\n {\n \"baseId\": \"QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N\",\n \"baseName\": \"文件扫描测试(25)\"\n },\n {\n \"baseId\": \"Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq\",\n \"baseName\": \"文件扫描测试(5)\"\n },\n {\n \"baseId\": \"OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4\",\n \"baseName\": \"文件扫描测试(24)\"\n },\n {\n \"baseId\": \"OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4\",\n \"baseName\": \"文件扫描测试(22)\"\n },\n {\n \"baseId\": \"l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4\",\n \"baseName\": \"文件扫描测试(26)(2)\"\n },\n {\n \"baseId\": \"MyQA2dXW7oOA63Yac5lMpzxkWzlwrZgb\",\n \"baseName\": \"文件扫描测试\"\n },\n {\n \"baseId\": \"9E05BDRVQePjzLkZtyX1L6vmV63zgkYA\",\n \"baseName\": \"工作目录文件扫描测试\"\n },\n {\n \"baseId\": \"y20BglGWOGQgpOj6u0GaqL3EWA7depqY\",\n \"baseName\": \"case-0055-数据导入测试\"\n },\n {\n \"baseId\": \"14dA3GK8gkP4oNl0cKqRz7jy89ekBD76\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-22 19:41:26\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSGLpk7EdJMwvDqPk\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-22 18:41:20\"\n },\n {\n \"baseId\": \"7dx2rn0Jbakrq9O5t2GbL30XVMGjLRb3\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-22 16:33:50\"\n },\n {\n \"baseId\": \"OG9lyrgJPL7M2ONQSzPgGxwvJzN67Mw4\",\n \"baseName\": \"case-0052-临时测试\"\n },\n {\n \"baseId\": \"wva2dxOW4kO6dpRac0jppNE58bkz3BRL\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-21 22:11:30\"\n },\n {\n \"baseId\": \"0eMKjyp81MrAdNmPu4NvYXAE8xAZB1Gv\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-20 21:24:44\"\n },\n {\n \"baseId\": \"dxXB52LJqwZrolvyFMkR2MDPWqjMp697\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-20 16:53:48\"\n },\n {\n \"baseId\": \"bva6QBXJwZenolNyS2Gwg2NxVn4qY5Pr\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-20 15:53:56\"\n },\n {\n \"baseId\": \"bva6QBXJwZenolNyS2G3bNnOVn4qY5Pr\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-20 09:09:21\"\n },\n {\n \"baseId\": \"pYLaezmVNGmDXOMxuGjwevPqJrMqPxX6\",\n \"baseName\": \"🧪测试 工作区目录扫描 - 2026-04-20 08:09:11\"\n }\n ],\n \"count\": 20\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1628 - }, - { - "service": "aitable", - "command": "+chart-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +chart-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --chart-id widget-dlxFo0tNSImp4ITpDn5fQ --yes --format json", - "args": [ - "aitable", - "+chart-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--dashboard-id", - "KY9tlWg5NEHgfs8WT6IO2", - "--chart-id", - "widget-dlxFo0tNSImp4ITpDn5fQ", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-dlxFo0tNSImp4ITpDn5fQ\",\n \"chartName\": \"各业务线未部署数\",\n \"chartType\": \"BAR\",\n \"config\": {\n \"axisLabelShow\": false,\n \"categoryAxisMaxRatio\": \"0.3\",\n \"chartType\": \"BAR\",\n \"colors\": \"COLOR_PALETTE_1\",\n \"dimension\": [\n {\n \"value\": \"rjwzzIu\"\n }\n ],\n \"fillType\": \"solid\",\n \"filter\": [],\n \"gridShow\": false,\n \"group\": [\n {\n \"value\": \"none\"\n }\n ],\n \"label\": true,\n \"labelHideOverlap\": true,\n \"legend\": \"top\",\n \"measure\": [\n {\n \"externalValue\": [\n {\n \"type\": \"formula\",\n \"value\": \"count\"\n }\n ],\n \"value\": \"kFwb8br\"\n }\n ],\n \"measureType\": \"field\",\n \"nullValue\": \"ignore\",\n \"sheet\": \"hERWDMS\",\n \"sortRule\": \"asc\",\n \"view\": \"\",\n \"xAbbr\": {\n \"abbr\": \"\",\n \"decimal\": \"\",\n \"numberType\": \"default\"\n },\n \"xAxisLabel\": true,\n \"xAxisLabelRotate\": \"auto\",\n \"xAxisLine\": false,\n \"xAxisShow\": true,\n \"xSortBy\": \"x\",\n \"xSortCustom\": [],\n \"yAbbr\": {\n \"abbr\": \"\",\n \"decimal\": \"\",\n \"numberType\": \"default\"\n },\n \"yAxisLabel\": true,\n \"yAxisLine\": false,\n \"yAxisRange\": {\n \"range\": [],\n \"type\": \"absolute\"\n },\n \"yAxisShow\": true,\n \"yRightAbbr\": {\n \"abbr\": \"\",\n \"decimal\": \"\",\n \"numberType\": \"default\"\n },\n \"ySortCustom\": []\n },\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\",\n \"layout\": {\n \"h\": 5,\n \"i\": \"widget-dlxFo0tNSImp4ITpDn5fQ\",\n \"w\": 6,\n \"x\": 0,\n \"y\": 4\n }\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Got chart widget-dlxFo0tNSImp4ITpDn5fQ\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1038 - }, - { - "service": "aitable", - "command": "+chart-widgets-example", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +chart-widgets-example --yes --format json", - "args": [ - "aitable", - "+chart-widgets-example", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": \"// 注意\\n// 1. 下述 JSON 示例中的注释(// 开头的注释)仅用于说明,实际使用时请删除,否则会导致解析错误\\n// 2. 所有图表类型的 widget config 示例,key 是图表类型,value 是对应的完整 config\\n// 3. 创建图表时,必需使用示例中的图表类型和配置格式\\n\\n\\n{\\n// 聚合列表视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"AGGREGATE_LIST_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"AGGREGATE_LIST_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"聚合列表视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 列表自定义配置,用于设置聚合列表的自定义样式和行为\\n \\\"listCustom\\\": {},\\n // 列表配置,用于设置聚合列表的显示方式和布局\\n \\\"listConfig\\\": {},\\n // 字段聚合映射,用于设置字段的聚合方式(如 sum、count、avg 等)\\n \\\"fieldAggregateMap\\\": {},\\n // 工具栏配置,用于控制聚合列表视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示分组按钮\\n \\\"enableGroup\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n },\\n },\\n// AI 分析卡片配置格式,必须按照此格式配置,否则会导致解析错误\\n // 注意:AI 分析卡片不依赖数据表,无需配置 sheet / view / filter\\n \\\"AI_ANALYZE\\\": {\\n // 必填,图表类型,固定值 \\\"AI_ANALYZE\\\"\\n \\\"chartType\\\": \\\"AI_ANALYZE\\\",\\n // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\\n \\\"name\\\": \\\"AI 分析卡片\\\",\\n // 可选,AI 提示词,用自然语言描述希望 AI 分析的内容和问题,\\n // 例如:\\\"分析本月各产品线的销售额趋势,找出增长最快的类别并给出建议\\\"\\n \\\"prompt\\\": \\\"\\\",\\n // 可选,需要 AI 参考分析的图表 ID 列表,数组中每个元素必须是当前仪表盘中已存在的图表 ID;\\n // 不填或传空数组时 AI 将基于提示词独立分析,不关联具体图表数据\\n \\\"analyzeChartIds\\\": [],\\n // 可选,指定 AI 模型 ID;不填时使用系统默认模型\\n \\\"modelId\\\": \\\"\\\",\\n // 只读,AI 生成的 Markdown 分析内容,由系统写入,创建时无需填写\\n \\\"content\\\": \\\"\\\",\\n // 只读,AI 分析结果的最后更新时间戳(毫秒),由系统写入,创建时无需填写\\n \\\"lastUpdatedAt\\\": null\\n },\\n// 面积图配置格式\\n \\\"AREA\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"AREA\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"面积图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_date_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"smooth\\\": false,\\n \\\"symbolType\\\": \\\"always\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"label\\\": false,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"filter\\\": []\\n }\\n// 条形图配置格式\\n \\\"BAR\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"BAR\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"条形图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"record-count\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"nullValue\\\": \\\"ignore\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"gridShow\\\": false,\\n \\\"label\\\": true,\\n \\\"labelHideOverlap\\\": true,\\n \\\"xAxisShow\\\": true,\\n \\\"xAxisLine\\\": false,\\n \\\"xAxisLabel\\\": true,\\n \\\"categoryAxisMaxRatio\\\": \\\"0.3\\\",\\n \\\"yAxisShow\\\": true,\\n \\\"yAxisLine\\\": false,\\n \\\"yAxisLabel\\\": true,\\n \\\"fillType\\\": \\\"solid\\\",\\n \\\"filter\\\": []\\n }\\n// 按钮配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"BUTTON\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"BUTTON\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"按钮\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 可选,按钮样式类型,合法值有:type1 到 type10,默认为 type1\\n \\\"buttonStyleType\\\": \\\"type1\\\",\\n // 可选,边框宽度,默认为 1\\n \\\"borderWidth\\\": 1,\\n // 可选,按钮大小,合法值有:default(默认)/ small(小)/ large(大),默认为 default\\n \\\"buttonSize\\\": \\\"default\\\",\\n // 可选,按钮图标,合法值有:link / download / refresh 等\\n \\\"buttonIcon\\\": \\\"link\\\",\\n // 可选,按钮图标背景类型,合法值有:light(浅色)/ dark(深色),默认为 light\\n \\\"buttonIconBackgroundType\\\": \\\"light\\\",\\n // 可选,按钮字体配置\\n \\\"buttonFontSetting\\\": {\\n // 字体大小,默认为 14\\n \\\"size\\\": 14,\\n // 字体颜色\\n \\\"color\\\": \\\"\\\",\\n // 字间距,默认为 0\\n \\\"letterSpacing\\\": 0,\\n // 是否加粗,默认为 false\\n \\\"bold\\\": false,\\n // 是否斜体,默认为 false\\n \\\"italic\\\": false,\\n // 是否下划线,默认为 false\\n \\\"underline\\\": false\\n }\\n },\\n// 日历视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"CALENDAR_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"CALENDAR_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"日历视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 视图分组配置,用于设置视图的默认分组规则\\n \\\"viewGroup\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 日历自定义配置,这是日历视图的核心配置\\n \\\"calendarCustom\\\": {\\n // 必填,显示字段 ID(必须是该表中真实存在的字段 ID)\\n // 用于在日历中显示事件标题或描述\\n \\\"displayFieldId\\\": \\\"fld_text_01\\\",\\n // 必填,开始时间字段 ID(必须是该表中真实存在的日期字段 ID)\\n // 用于设置事件的开始时间\\n \\\"startField\\\": \\\"fld_date_01\\\",\\n // 必填,结束时间字段 ID(必须是该表中真实存在的日期字段 ID)\\n // 用于设置事件的结束时间\\n \\\"endField\\\": \\\"fld_date_02\\\",\\n // 标记配置,用于在日历中添加特殊标记或样式\\n \\\"markers\\\": [],\\n // 时间轴刻度,合法值有:day(日)/ week(周)/ month(月)\\n // 用于设置日历的显示粒度\\n \\\"timelineScale\\\": \\\"month\\\",\\n // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\\n \\\"widthMap\\\": {},\\n // 单元格高度,单位为像素,默认为 32\\n \\\"cellHeight\\\": 32\\n },\\n // 工具栏配置,用于控制日历视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示分组按钮\\n \\\"enableGroup\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n // 是否显示编辑记录按钮\\n \\\"enableEditRecord\\\": false\\n },\\n },\\n// 卡片列表视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"CARD_LIST_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"CARD_LIST_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"卡片列表视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 列表自定义配置,用于设置卡片列表的自定义样式和行为\\n \\\"listCustom\\\": {},\\n // 列表配置,用于设置卡片列表的显示方式和布局\\n \\\"listConfig\\\": {},\\n // 工具栏配置,用于控制卡片列表视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n },\\n },\\n// 折叠列表视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"COLLAPSIBLE_LIST_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"COLLAPSIBLE_LIST_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"折叠列表视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 列表自定义配置,用于设置折叠列表的自定义样式和行为\\n \\\"listCustom\\\": {},\\n // 列表配置,用于设置折叠列表的显示方式和布局\\n \\\"listConfig\\\": {},\\n // 工具栏配置,用于控制折叠列表视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示分组按钮\\n \\\"enableGroup\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n },\\n },\\n// 组合布局配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"COMPOSITE_LAYOUT\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"COMPOSITE_LAYOUT\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"组合布局\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\"\\n // 组合布局容器,无特殊配置字段,用于承载其他图表组件\\n },\\n// 组合图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"COMPOSITION\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"COMPOSITION\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"组合图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ],\\n \\\"composition\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"HISTOGRAM\\\",\\n \\\"yAxisPosition\\\": \\\"left\\\"\\n }\\n },\\n {\\n \\\"value\\\": \\\"fld_number_02\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"average\\\"\\n }\\n ],\\n \\\"composition\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"LINE\\\",\\n \\\"yAxisPosition\\\": \\\"right\\\"\\n }\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_date_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"gridShow\\\": false,\\n \\\"label\\\": true,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"filter\\\": []\\n },\\n// ═══════════════════════════════════════════════════════════════════\\n // 图表类型:倒计时(COUNTDOWN)\\n // 别名/同义词:倒计时、正计时、计时器、倒计时卡片\\n // 用途:用于展示距离某个目标日期的剩余时间或已过时间,\\n // 支持倒计时和正计时两种模式。适合展示活动截止时间、\\n // 项目里程碑、重要日期提醒等场景。\\n // ═══════════════════════════════════════════════════════════════════\\n // ⚠️ 重要注意事项:\\n // 1. fixedDatetime 必须是毫秒时间戳(如 1746028800000),不是日期字符串(如 \\\"2026-05-01\\\")!\\n // 2. unit 是一个对象(Object),不是字符串!不要写成 \\\"天\\\" 或 \\\"day\\\"!\\n // 3. 背景色字段名是 \\\"background\\\",不是 \\\"backgroundColor\\\"\\n // 4. 时间数字颜色字段名是 \\\"timeColor\\\",不是 \\\"numberColor\\\"\\n // 5. 描述文字颜色字段名是 \\\"descColor\\\",不是 \\\"titleColor\\\"\\n // 6. 不存在 \\\"countdownType\\\"、\\\"endDate\\\"、\\\"numberColor\\\"、\\\"titleColor\\\" 等字段,使用这些字段会导致图表渲染异常\\n \\\"COUNTDOWN\\\": {\\n // 必填,图表类型,固定值 \\\"COUNTDOWN\\\"\\n \\\"chartType\\\": \\\"COUNTDOWN\\\",\\n // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\\n \\\"name\\\": \\\"倒计时\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": null,\\n // 可选,描述文本,显示在倒计时数字上方\\n \\\"desc\\\": \\\"倒计时\\\",\\n // 必填,数据来源类型,合法值:fixed(固定日期时间)/ field(从数据表日期字段取值)\\n \\\"sourceType\\\": \\\"fixed\\\",\\n // sourceType = \\\"fixed\\\" 时必填,目标日期的毫秒时间戳\\n // ⚠️ 必须是毫秒时间戳数字(如 1746028800000),不是日期字符串(如 \\\"2026-05-01\\\")!\\n // 示例:2026-05-01 00:00:00 → 1746028800000\\n \\\"fixedDatetime\\\": 1746028800000,\\n // sourceType = \\\"field\\\" 时填写,日期字段的 fieldId\\n \\\"fieldId\\\": null,\\n // sourceType = \\\"field\\\" 时填写,日期选择方式,合法值:earliest(最早日期)/ latest(最晚日期)\\n \\\"fieldChoice\\\": \\\"earliest\\\",\\n // 必填,计时模式,合法值:countdown(倒计时,目标时间在未来)/ countup(正计时,目标时间在过去)\\n \\\"timingMode\\\": \\\"countdown\\\",\\n // 必填,显示的时间单位开关(是一个对象,不是字符串!)\\n // ⚠️ 不要写成 \\\"天\\\" 或 \\\"day\\\",必须是如下对象格式:\\n \\\"unit\\\": {\\n // 是否显示月,默认 false\\n \\\"month\\\": false,\\n // 是否显示周,默认 false\\n \\\"week\\\": false,\\n // 是否显示天,默认 true\\n \\\"day\\\": true,\\n // 是否显示小时,默认 true\\n \\\"hour\\\": true,\\n // 是否显示分钟,默认 true\\n \\\"minute\\\": true,\\n // 是否显示秒,默认 true\\n \\\"second\\\": true\\n },\\n\\n // ── 样式配置 ──────────────────────────────────────────────────────────────\\n // 可选,卡片背景颜色(⚠️ 字段名是 \\\"background\\\",不是 \\\"backgroundColor\\\")\\n \\\"background\\\": \\\"\\\",\\n // 可选,描述文字颜色(⚠️ 字段名是 \\\"descColor\\\",不是 \\\"titleColor\\\")\\n \\\"descColor\\\": \\\"\\\",\\n // 可选,时间块背景颜色\\n \\\"timeBlockColor\\\": \\\"\\\",\\n // 可选,倒计时数字颜色(⚠️ 字段名是 \\\"timeColor\\\",不是 \\\"numberColor\\\")\\n \\\"timeColor\\\": \\\"\\\",\\n // 可选,计时完成是否显示动效,默认 false\\n \\\"showFinishEffect\\\": false\\n },\\n// 详情列表视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"DETAIL_LIST_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"DETAIL_LIST_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"详情列表视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 列表自定义配置,用于设置详情列表的自定义样式和行为\\n \\\"listCustom\\\": {},\\n // 列表配置,用于设置详情列表的显示方式和布局\\n \\\"listConfig\\\": {},\\n // 工具栏配置,用于控制详情列表视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n },\\n },\\n// 嵌入网页配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"EMBED_WEBPAGE\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"EMBED_WEBPAGE\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"嵌入网页\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 必填,嵌入的网页 URL(必须是有效的 URL 地址)\\n \\\"url\\\": \\\"https://example.com\\\"\\n },\\n// 漏斗图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"FUNNEL\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"FUNNEL\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"漏斗图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"record-count\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\"\\n }\\n ],\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"sumValue\\\": true,\\n \\\"funnelType\\\": \\\"normal\\\",\\n \\\"filter\\\": []\\n },\\n// 画册视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"GALLERY_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"GALLERY_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"画册视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 画册自定义配置,用于设置画册的显示样式\\n \\\"galleryCustom\\\": {\\n // 卡片设置,用于配置画册中卡片的显示方式\\n \\\"cardSetting\\\": {\\n // 封面模式,合法值有:none(无封面)/ cover(封面)等\\n \\\"coverMode\\\": \\\"none\\\"\\n }\\n },\\n // 工具栏配置,用于控制画册视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n // 是否显示编辑记录按钮\\n \\\"enableEditRecord\\\": false\\n },\\n },\\n// 甘特视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"GANTT_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"GANTT_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"甘特视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 视图分组配置,用于设置视图的默认分组规则\\n \\\"viewGroup\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 是否折叠网格,默认为 false\\n \\\"collapsedGrid\\\": false,\\n // 甘特自定义配置,这是甘特视图的核心配置\\n \\\"ganttCustom\\\": {\\n // 必填,显示字段 ID(必须是该表中真实存在的字段 ID)\\n // 用于在甘特图中显示任务名称或描述\\n \\\"displayFieldId\\\": \\\"fld_text_01\\\",\\n // 必填,开始时间字段 ID(必须是该表中真实存在的日期字段 ID)\\n // 用于设置任务的开始时间\\n \\\"startField\\\": \\\"fld_date_01\\\",\\n // 必填,结束时间字段 ID(必须是该表中真实存在的日期字段 ID)\\n // 用于设置任务的结束时间\\n \\\"endField\\\": \\\"fld_date_02\\\",\\n // 标记配置,用于在甘特图中添加里程碑或重要时间点\\n \\\"markers\\\": [],\\n // 时间轴刻度,合法值有:day(日)/ week(周)/ month(月)/ quarter(季)/ year(年)\\n // 用于设置甘特图的时间轴显示粒度\\n \\\"timelineScale\\\": \\\"month\\\",\\n // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\\n \\\"widthMap\\\": {},\\n // 单元格高度,单位为像素,默认为 32\\n \\\"cellHeight\\\": 32\\n },\\n // 工具栏配置,用于控制甘特视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示分组按钮\\n \\\"enableGroup\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n // 是否显示编辑记录按钮\\n \\\"enableEditRecord\\\": false\\n },\\n },\\n// 表格视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"GRID_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"GRID_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"表格视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 视图分组配置,用于设置视图的默认分组规则\\n \\\"viewGroup\\\": [],\\n // 单元格高度,单位为像素,默认为 32\\n \\\"cellHeight\\\": 32,\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列宽度映射,key 为 fieldId,value 为列宽度(像素)\\n \\\"widthMap\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 冻结列数,默认为 1,表示冻结左侧的列数\\n \\\"frozenColCount\\\": 1,\\n // 工具栏配置,用于控制表格视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示分组按钮\\n \\\"enableGroup\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 创建行按钮的文本内容\\n \\\"createRowText\\\": \\\"\\\",\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n // 是否显示编辑记录按钮\\n \\\"enableEditRecord\\\": false\\n },\\n },\\n// ═══════════════════════════════════════════════════════════════════\\n // 图表类型:柱状图(HISTOGRAM)\\n // 别名/同义词:竖向柱图、垂直柱图、分组柱图\\n // 用途:以垂直柱子展示多个分类维度的数值对比,适合比较不同类别之间的\\n // 数量差异,如各月销售额对比、各部门人数对比、各产品销量排名等。\\n // 核心特征:有 X 轴(分类维度)和 Y 轴(数值),每个类别对应一根柱子。\\n // 与\\\"统计数字(SUMMARY)\\\"的关键区别:\\n // - 柱状图(HISTOGRAM):展示多个分类的对比,有坐标轴,是\\\"多值图表\\\"\\n // - 统计数字(SUMMARY):只展示一个聚合数字,无坐标轴,是\\\"单值卡片\\\"\\n // 重要:当用户说\\\"柱状图\\\"、\\\"柱图\\\"、\\\"分组柱图\\\"时,\\n // 必须使用 chartType = \\\"HISTOGRAM\\\",不要使用 SUMMARY 或其他图表类型。\\n // ═══════════════════════════════════════════════════════════════════\\n \\\"HISTOGRAM\\\": {\\n // 必填,图表类型,固定值 \\\"HISTOGRAM\\\"(柱状图)\\n \\\"chartType\\\": \\\"HISTOGRAM\\\",\\n // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\\n \\\"name\\\": \\\"柱状图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"record-count\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_02\\\"\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"xSortCustom\\\": [],\\n \\\"ySortCustom\\\": [],\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"nullValue\\\": \\\"ignore\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"gridShow\\\": false,\\n \\\"axisLabelShow\\\": false,\\n \\\"label\\\": true,\\n \\\"labelHideOverlap\\\": true,\\n \\\"xAxisShow\\\": true,\\n \\\"xAxisLine\\\": false,\\n \\\"xAxisLabel\\\": true,\\n \\\"xAxisLabelRotate\\\": \\\"auto\\\",\\n \\\"categoryAxisMaxRatio\\\": \\\"0.2\\\",\\n \\\"yAxisShow\\\": true,\\n \\\"yAxisLine\\\": false,\\n \\\"yAxisLabel\\\": true,\\n \\\"fillType\\\": \\\"solid\\\",\\n \\\"yAxisRange\\\": {\\n \\\"type\\\": \\\"absolute\\\",\\n \\\"range\\\": []\\n },\\n \\\"xAbbr\\\": {\\n \\\"numberType\\\": \\\"default\\\",\\n \\\"abbr\\\": \\\"\\\",\\n \\\"decimal\\\": \\\"\\\"\\n },\\n \\\"yAbbr\\\": {\\n \\\"numberType\\\": \\\"default\\\",\\n \\\"abbr\\\": \\\"\\\",\\n \\\"decimal\\\": \\\"\\\"\\n },\\n \\\"yRightAbbr\\\": {\\n \\\"numberType\\\": \\\"default\\\",\\n \\\"abbr\\\": \\\"\\\",\\n \\\"decimal\\\": \\\"\\\"\\n },\\n \\\"filter\\\": []\\n }\\n// ═══════════════════════════════════════════════════════════════════\\n // 图表类型:图片(IMAGE)\\n // 别名/同义词:图片卡、图片展示、图片轮播、Banner 图\\n // 用途:在仪表盘中展示一张或多张静态图片,支持多图轮播(滑动/淡入淡出),\\n // 可为每张图片配置标题、描述和点击跳转链接。适合展示品牌 Banner、\\n // 产品图、说明图、装饰图等不依赖数据表的纯图片内容。\\n // 重要:当用户说\\\"图片\\\"、\\\"图片卡\\\"、\\\"图片展示\\\"、\\\"Banner\\\"、\\\"轮播图\\\"时,\\n // 必须使用 chartType = \\\"IMAGE\\\",不要使用其他图表类型。\\n // 此图表类型不依赖数据表,无需配置 sheet / view / filter。\\n // ═══════════════════════════════════════════════════════════════════\\n \\\"IMAGE\\\": {\\n // 必填,图表类型,固定值 \\\"IMAGE\\\"(图片)\\n \\\"chartType\\\": \\\"IMAGE\\\",\\n // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\\n \\\"name\\\": \\\"图片\\\",\\n // 必填,图片列表,至少包含一张图片;多张图片时支持轮播\\n \\\"img_images\\\": [\\n {\\n // 必填,图片的访问 URL\\n \\\"url\\\": \\\"https://example.com/image.png\\\",\\n // 可选,图片标题,显示在图片上方(需 img_showImgText=true)\\n \\\"title\\\": \\\"\\\",\\n // 可选,图片描述,显示在标题下方(需 img_showImgText=true)\\n \\\"desc\\\": \\\"\\\",\\n // 可选,点击图片后跳转的链接 URL\\n \\\"toUrl\\\": \\\"\\\",\\n // 必填,图片填充模式,合法值:cover(裁剪铺满)/ contain(等比缩放留白)/ fill(拉伸铺满)\\n \\\"fillMode\\\": \\\"cover\\\"\\n }\\n ],\\n // 可选,是否在图片上显示标题和描述文字,默认 true;\\n // 设为 false 时 img_titleStyle / img_descStyle / img_textAlign 均不生效\\n \\\"img_showImgText\\\": true,\\n // 可选,图片文字的对齐位置,合法值:\\n // leftTop / top / rightTop / left / center / right / leftBottom / bottom / rightBottom\\n \\\"img_textAlign\\\": \\\"center\\\",\\n // 可选,图片标题的字体样式(仅 img_showImgText=true 时生效)\\n \\\"img_titleStyle\\\": {\\n // 字体大小(px),合法范围 16~56,步长 4\\n \\\"size\\\": 16,\\n // 字体颜色,十六进制色值,如 \\\"#FFFFFF\\\"\\n \\\"color\\\": \\\"\\\",\\n // 是否加粗\\n \\\"bold\\\": true,\\n // 是否斜体\\n \\\"italic\\\": false,\\n // 是否下划线\\n \\\"underline\\\": false\\n },\\n // 可选,图片描述的字体样式(仅 img_showImgText=true 时生效)\\n \\\"img_descStyle\\\": {\\n // 字体大小(px),合法范围 16~56,步长 4\\n \\\"size\\\": 14,\\n // 字体颜色,十六进制色值\\n \\\"color\\\": \\\"\\\",\\n // 是否加粗\\n \\\"bold\\\": false,\\n // 是否斜体\\n \\\"italic\\\": false,\\n // 是否下划线\\n \\\"underline\\\": false\\n },\\n // 可选,多图时是否自动轮播,默认 false;单张图片时此配置无效\\n \\\"img_autoPlay\\\": false,\\n // 可选,多图轮播切换动效,合法值:slide(滑动)/ fade(淡入淡出),默认 slide;单张图片时无效\\n \\\"img_imgTransitionType\\\": \\\"slide\\\",\\n // 可选,多图自动轮播间隔(秒),合法范围 0.1~1000,默认 1;仅 img_autoPlay=true 时生效\\n \\\"img_imgTransitionInterval\\\": 1\\n },\\n// 看板视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"KANBAN_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"KANBAN_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"看板视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 看板自定义配置,这是看板视图的核心配置\\n \\\"kanbanCustom\\\": {\\n // 分组基础配置,用于设置看板的分组字段\\n \\\"groupBase\\\": {\\n // 必填,分组字段 ID(必须是该表中真实存在的字段 ID)\\n // 看板将按照此字段的值进行分组显示\\n \\\"baseFieldId\\\": \\\"fld_select_01\\\"\\n }\\n },\\n // 工具栏配置,用于控制看板视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n // 是否显示编辑记录按钮\\n \\\"enableEditRecord\\\": false\\n },\\n },\\n// 排行榜配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"LEADERBOARD\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"LEADERBOARD\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"排行榜\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"record-count\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_text_01\\\"\\n }\\n ],\\n \\\"showType\\\": \\\"podium\\\", // 展示类型:podium 领奖台/list 列表\\n \\\"showCount\\\": 10, // 显示条数\\n \\\"hideValue\\\": false,\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"filter\\\": []\\n },\\n// 折线图配置格式\\n \\\"LINE\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"LINE\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"折线图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_date_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"nullValue\\\": \\\"ignore\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"gridShow\\\": false,\\n \\\"label\\\": true,\\n \\\"labelHideOverlap\\\": true,\\n \\\"smooth\\\": false,\\n \\\"symbolType\\\": \\\"always\\\",\\n \\\"xAxisShow\\\": true,\\n \\\"xAxisLine\\\": false,\\n \\\"xAxisLabel\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"yAxisLine\\\": false,\\n \\\"yAxisLabel\\\": true,\\n \\\"fillType\\\": \\\"solid\\\",\\n \\\"filter\\\": []\\n }\\n// 抽奖配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"LOTTERY\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"LOTTERY\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"抽奖\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 必填,指标类型,合法值有:record-count(记录数)/ field(字段)\\n \\\"measureType\\\": \\\"record-count\\\",\\n // 必填,指标配置数组\\n \\\"measure\\\": [],\\n // 可选,输出表 ID,用于记录中奖结果\\n \\\"outputSheet\\\": \\\"\\\",\\n // 可选,滚动速度,默认为 5\\n \\\"speed\\\": 5,\\n // 可选,字体大小,默认为 20\\n \\\"fontSize\\\": 20,\\n // 可选,颜色主题,合法值有:COLOR_PALETTE_1 到 COLOR_PALETTE_10\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n // 必填,奖项配置数组,每个元素格式为 [数量, 名称]\\n \\\"gameRounds\\\": [\\n [\\n 1,\\n \\\"一等奖\\\"\\n ]\\n ],\\n // 可选,是否包含已中奖者,默认为 false\\n \\\"includeWinners\\\": false\\n },\\n// 跑马灯配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"MARQUEE\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"MARQUEE\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"跑马灯\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 可选,滚动文本内容\\n \\\"content\\\": \\\"这是一条滚动消息\\\",\\n // 可选,字体大小,默认为 14\\n \\\"fontSize\\\": 14,\\n // 可选,滚动速度,数值越小速度越快,默认为 50\\n \\\"speed\\\": 50,\\n // 可选,字体配置\\n \\\"fontSetting\\\": {\\n // 字体颜色\\n \\\"color\\\": \\\"\\\",\\n // 是否加粗,默认为 false\\n \\\"bold\\\": false,\\n // 是否斜体,默认为 false\\n \\\"italic\\\": false,\\n // 是否下划线,默认为 false\\n \\\"underline\\\": false\\n }\\n },\\n// NPS评分配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"NPS\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"NPS\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"NPS评分\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\"\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\"\\n }\\n ],\\n \\\"showLabel\\\": true,\\n \\\"showList\\\": false,\\n \\\"threshold\\\": [ // 评分阈值数组\\n 2,\\n 3\\n ],\\n \\\"colorSetName\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"filter\\\": []\\n },\\n// 百分比堆叠面积图配置格式\\n \\\"PERCENT_STACKED_AREA\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"PERCENT_STACKED_AREA\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"百分比堆叠面积图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_date_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\"\\n }\\n ],\\n \\\"smooth\\\": false,\\n \\\"symbolType\\\": \\\"always\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"label\\\": true,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"filter\\\": []\\n }\\n// 百分比堆叠条形图配置格式\\n \\\"PERCENT_STACKED_BAR\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"PERCENT_STACKED_BAR\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"百分比堆叠条形图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_02\\\"\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"label\\\": true,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"categoryAxisMaxRatio\\\": \\\"0.3\\\",\\n \\\"filter\\\": []\\n }\\n// 百分比堆叠柱状图配置格式\\n \\\"PERCENT_STACKED_HISTOGRAM\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"PERCENT_STACKED_HISTOGRAM\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"百分比堆叠柱状图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_02\\\"\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"label\\\": true,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"filter\\\": []\\n }\\n// 饼图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"PIE\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"PIE\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"饼图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\", // 必须使用示例中的默认值如 COLOR_PALETTE_1,禁止自行编造\\n \\\"legend\\\": \\\"none\\\",\\n \\\"label\\\": true, // 必须设为 true 以默认显示数据标签\\n \\\"innerRadius\\\": 0, // 内半径,0 为实心饼图\\n \\\"outerRadius\\\": 60, // 外半径百分比\\n \\\"filter\\\": []\\n },\\n// 透视表配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"PIVOT\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"PIVOT\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"透视表\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 透视表配置,这是透视表的核心配置\\n \\\"pivotConfig\\\": {\\n // 必填,rows 是行字段列表,至少要有一个行字段,\\n // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\\n // 如果多层分组,行字段列表中多个字段的 fieldId 依次对应每层分组的字段 ID\\n \\\"rows\\\": [\\n {\\n // 必填,行字段 ID(必须是该表中真实存在的字段 ID)\\n \\\"fld\\\": \\\"fld_01\\\",\\n // 可选,排序方式,合法值有:1(升序)和 -1(降序),默认为 1 升序\\n \\\"srt\\\": 1\\n }\\n ],\\n // 可选,columns 是列字段列表,默认为空数组,\\n // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\\n // 如果多层分组,列字段列表中多个字段的 fieldId 依次对应每层分组的字段 ID\\n \\\"columns\\\": [\\n {\\n // 必填,列字段 ID(必须是该表中真实存在的字段 ID)\\n \\\"fld\\\": \\\"fld_02\\\",\\n // 可选,排序方式,合法值有:1(升序)和 -1(降序),默认为 1 升序\\n \\\"srt\\\": 1\\n }\\n ],\\n // 必填,values 是值字段列表,至少要有一个值字段,\\n // 每个字段配置中 fieldId 必须是该表中真实存在的字段 ID\\n // 值字段,是用于计算值的字段,通常是数值字段,但也可以是其他类型的字段(如文本字段),具体取决于聚合函数的要求\\n \\\"values\\\": [\\n {\\n // 必填,值字段 ID(必须是该表中真实存在的字段 ID)\\n \\\"fld\\\": \\\"fld_03\\\",\\n //必填,聚合函数,合法值有:sum /count / count_distinct / avg / min / max / median / population_variance / sample_variance / population_stddev / sample_stddev / first / last / range / mode / sum_of_squares / harmonic_mean / geometric_mean / interquartile_range / centered_mean / weighted_mean / kurtosis / skewness / quartile_median / range_median / coefficient_of_variation / adjusted_stddev / weighted_sum / weighted_count / weighted_count_distinct / weighted_avg / centered_weighted_mean / weighted_harmonic_mean / weighted_geometric_mean / weighted_range / weighted_range_median / weighted_interquartile_range / weighted_quartile_median / weighted_centered_mean / weighted_adjusted_stddev\\n \\\"agg\\\": \\\"sum\\\",\\n //可选,后聚合函数,合法值有:default / percent_of_row_total / percent_of_col_total / percent_of_grand_total\\n \\\"pgg\\\": \\\"string\\\",\\n //可选,格式化函数,合法值有:default / percentage / thousands / currency / scientific / fixed / integer\\n \\\"fmt\\\": \\\"\\\",\\n //可选,字段别名\\n \\\"als\\\": \\\"string\\\"\\n }\\n ],\\n // 是否显示行总计,默认为 true\\n \\\"rowTotals\\\": true,\\n // 是否显示列总计,默认为 true\\n \\\"columnTotals\\\": true\\n },\\n \\\"pivotStyle\\\": {\\n // 可选,透视表在卡片中的对齐方式,合法值有: start(上部)/ center(中间)\\n \\\"verticalAlign\\\": \\\"start\\\"\\n }\\n },\\n// 地图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"PLOT_MAP\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"PLOT_MAP\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"地图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 地图配置,这是地图的核心配置\\n \\\"plotMapConfig\\\": {\\n // 数据源配置\\n \\\"source\\\": {\\n // 数据源类型,合法值有:view(视图)\\n \\\"type\\\": \\\"view\\\",\\n // 数据源名称,格式为 \\\"sheetId:viewId\\\"\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"sheet_001:view_001\\\"\\n },\\n // 渲染器类型,合法值有:marker(标记点)/ heatmap(热力图)等\\n // 用于设置地图的渲染方式\\n \\\"renderer\\\": \\\"marker\\\",\\n // 处理器,用于对数据进行预处理\\n \\\"processor\\\": \\\"\\\",\\n // 维度配置,用于设置地图的维度字段(如地理位置字段)\\n \\\"dimensions\\\": [\\n {\\n // 必填,维度字段 ID(必须是该表中真实存在的字段 ID)\\n // 通常为位置字段,用于在地图上定位\\n \\\"fieldId\\\": \\\"fld_location_01\\\"\\n }\\n ],\\n // 度量配置,用于设置地图的度量字段(如数值字段)\\n \\\"measures\\\": [\\n {\\n // 必填,度量字段 ID(必须是该表中真实存在的字段 ID)\\n // 用于在地图上显示数值大小或颜色\\n \\\"fieldId\\\": \\\"fld_number_01\\\"\\n }\\n ],\\n // 样式配置,用于设置地图的显示样式\\n \\\"styles\\\": []\\n },\\n },\\n// ═══════════════════════════════════════════════════════════════════\\n // 图表类型:进度图(PROGRESS)\\n // 别名/同义词:进度条、进度环、进度卡、进度图表\\n // 用途:用于展示某项目标的完成进度,支持当前值 vs 目标值的对比,\\n // 可视化呈现完成百分比。适合展示销售目标达成率、任务完成度、\\n // 日期时间进度等场景。\\n // 重要:当用户说\\\"进度图\\\"、\\\"进度条\\\"、\\\"进度环\\\"、\\\"目标完成度\\\"时,\\n // 必须使用 chartType = \\\"PROGRESS\\\",不要使用其他图表类型。\\n // ═══════════════════════════════════════════════════════════════════\\n //\\n // ⚠️⚠️⚠️ 极其重要的注意事项(不遵守会导致图表渲染报错):\\n //\\n // 1. currentMeasure / targetMeasure 的格式是一个对象(Object),绝对不是数组(Array)!\\n // 不要使用普通图表的 measure 数组格式 [{ externalValue, value }],否则图表会报错!\\n //\\n // 2. 必须包含以下顶层通用字段,否则图表校验会失败并报「表格字段发生变化」错误:\\n // - \\\"measureType\\\": \\\"record-count\\\" ← 必填!缺少此字段图表必定报错\\n // - \\\"sheet\\\": \\\"\\u003csheetId\\u003e\\\" ← 必填!设置为 currentSheet 相同的值\\n // - \\\"view\\\": \\\"\\\" ← 必填!设置为空字符串\\n // - \\\"filter\\\": [] ← 必填!设置为空数组\\n //\\n // 3. currentFilter / targetFilter 中的每个筛选条件必须包含 \\\"link\\\" 字段:\\n // 正确:{ \\\"fieldId\\\": \\\"xxx\\\", \\\"symbol\\\": \\\"EQ\\\", \\\"value\\\": \\\"yyy\\\", \\\"link\\\": \\\"AND\\\" }\\n // 错误:{ \\\"fieldId\\\": \\\"xxx\\\", \\\"symbol\\\": \\\"EQ\\\", \\\"value\\\": \\\"yyy\\\" } ← 缺少 link!\\n // 注意:单选字段的 value 必须是选项的 ID(如 \\\"PO5PWsAqQi\\\"),不能是选项的显示文本(如 \\\"影院观影\\\")!\\n // 使用文本作为 value 会导致筛选条件无法匹配任何记录,当前值始终为 0。\\n //\\n // 4. 即使 targetSource 为 \\\"custom\\\",也必须提供 targetSheet、targetView、targetFilter、targetMeasure 的默认值\\n // 即使 currentSource 为 \\\"custom\\\",也必须提供 currentSheet、currentView、currentFilter、currentMeasure 的默认值\\n //\\n // 5. 必须包含 numberFormat 和 fixedCount 字段,否则当前值/目标值会显示异常(数值被乘以100、出现双%%、文案被挤掉不显示):\\n // - \\\"numberFormat\\\": { \\\"digits\\\": 0, \\\"format\\\": \\\"number\\\", \\\"thousandSeparator\\\": true } ← format 必须是 \\\"number\\\"!\\n // - \\\"fixedCount\\\": 0\\n //\\n \\\"PROGRESS\\\": {\\n // ── 顶层通用字段(必填,缺少会导致图表校验失败)─────────────────────────\\n // 必填,图表类型,固定值 \\\"PROGRESS\\\"(进度图)\\n \\\"chartType\\\": \\\"PROGRESS\\\",\\n // ⚠️ 必填,统计类型,进度图固定为 \\\"record-count\\\"\\n \\\"measureType\\\": \\\"record-count\\\",\\n // ⚠️ 必填,顶层数据表 ID,设置为 currentSheet 相同的值\\n \\\"sheet\\\": \\\"\\u003csheetId\\u003e\\\",\\n // ⚠️ 必填,顶层视图 ID,设置为空字符串\\n \\\"view\\\": \\\"\\\",\\n // ⚠️ 必填,顶层筛选条件,设置为空数组\\n \\\"filter\\\": [],\\n\\n // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\\n \\\"name\\\": \\\"进度图\\\",\\n // 必填,数据类型,合法值:number(数值进度)/ date(日期进度)\\n \\\"dataType\\\": \\\"number\\\",\\n // 必填,图表形状,合法值:bar(条形)/ semi-circle(半环)/ circle(圆环)\\n \\\"progressType\\\": \\\"bar\\\",\\n\\n // ── 目标值配置 ──────────────────────────────────────────────────────────\\n // 目标值来源,合法值:custom(自定义数值)/ sheet(从数据表字段聚合)\\n \\\"targetSource\\\": \\\"custom\\\",\\n // targetSource = \\\"custom\\\" 时填写,目标值的固定数值字符串\\n \\\"target\\\": \\\"500\\\",\\n // 必填,目标值来源的数据表 ID,即使 targetSource = \\\"custom\\\" 也要填写,设置为 currentSheet 相同的值\\n \\\"targetSheet\\\": \\\"\\u003csheetId\\u003e\\\",\\n // 必填,目标值来源的视图 ID,设置为空字符串\\n \\\"targetView\\\": \\\"\\\",\\n // 必填,目标值来源的过滤条件,targetSource = \\\"custom\\\" 时设置为空数组\\n \\\"targetFilter\\\": [],\\n // 必填,目标值聚合字段配置,targetSource = \\\"custom\\\" 时设置为默认值\\n // ⚠️ 格式必须是对象,不是数组!详见下方 currentMeasure 的格式说明,targetMeasure 格式完全相同\\n \\\"targetMeasure\\\": { \\\"type\\\": \\\"record-count\\\", \\\"fields\\\": [] },\\n // 可选,目标值的标签文字,显示在目标值旁边\\n \\\"targetText\\\": \\\"\\\",\\n\\n // ── 当前值配置 ──────────────────────────────────────────────────────────\\n // 当前值来源,合法值:custom(自定义数值)/ sheet(从数据表字段聚合)\\n \\\"currentSource\\\": \\\"custom\\\",\\n // currentSource = \\\"custom\\\" 时填写,当前值的固定数值字符串\\n \\\"current\\\": \\\"100\\\",\\n // 必填,当前值来源的数据表 ID\\n \\\"currentSheet\\\": \\\"\\u003csheetId\\u003e\\\",\\n // 必填,当前值来源的视图 ID,设置为空字符串表示全部数据\\n \\\"currentView\\\": \\\"\\\",\\n // 必填,当前值来源的过滤条件,currentSource = \\\"custom\\\" 时设置为空数组\\n // ⚠️ 每个筛选条件必须包含 \\\"link\\\" 字段!格式:\\n // { \\\"fieldId\\\": \\\"\\u003cfieldId\\u003e\\\", \\\"symbol\\\": \\\"EQ\\\", \\\"value\\\": \\\"\\u003c选项ID\\u003e\\\", \\\"link\\\": \\\"AND\\\" }\\n // 多个条件时,第一个条件的 link 也必须填 \\\"AND\\\"\\n \\\"currentFilter\\\": [],\\n // 必填,当前值聚合字段配置\\n // ═══════════════════════════════════════════════════════════════════\\n // ⚠️⚠️⚠️ 极其重要:currentMeasure 是一个 Object,不是 Array!\\n // 绝对不要写成 [{ externalValue: [...], value: \\\"xxx\\\" }] 这种数组格式!\\n // 正确格式如下:\\n //\\n // 方式一:记录计数(统计满足条件的记录总数)\\n // { \\\"type\\\": \\\"record-count\\\", \\\"fields\\\": [] }\\n //\\n // 方式二:字段聚合(对某个字段做聚合计算)\\n // { \\\"type\\\": \\\"field\\\", \\\"fields\\\": [{ \\\"value\\\": \\\"\\u003cfieldId\\u003e\\\", \\\"formula\\\": \\\"\\u003c聚合函数\\u003e\\\" }] }\\n // formula 合法值:count / unique-count / sum / average / max / min\\n //\\n // 错误示例(绝对禁止):\\n // [{ \\\"externalValue\\\": [{ \\\"type\\\": \\\"formula\\\", \\\"value\\\": \\\"count\\\" }], \\\"value\\\": \\\"fieldId\\\" }]\\n // ═══════════════════════════════════════════════════════════════════\\n \\\"currentMeasure\\\": { \\\"type\\\": \\\"record-count\\\", \\\"fields\\\": [] },\\n // 可选,当前值的标签文字,显示在当前值旁边\\n \\\"currentText\\\": \\\"\\\",\\n\\n // ── 数值格式配置 ──────────────────────────────────────────────────────────\\n // ⚠️ 必填,百分比小数位数,缺少会导致小数位数异常,默认填 0\\n \\\"fixedCount\\\": 0,\\n // ⚠️ 必填,数字格式化配置(作用于当前值和目标值的显示)\\n // 缺少此字段会导致数值显示异常(数值被乘以100、出现双%%符号、文案被挤掉不显示)\\n \\\"numberFormat\\\": {\\n // 小数位数,默认 0\\n \\\"digits\\\": 0,\\n // ⚠️ 格式类型,进度图必须填 \\\"number\\\"!绝对不要填 \\\"percent\\\",否则数值会被乘以100并多出%符号\\n // 合法值:number / percent / thousand,进度图固定填 \\\"number\\\"\\n \\\"format\\\": \\\"number\\\",\\n // 是否显示千位分隔符\\n \\\"thousandSeparator\\\": true\\n },\\n // 可选,数值单位,如 \\\"¥\\\" / \\\"$\\\" / \\\"人\\\" / \\\"%\\\" 等自定义字符串,不填则无单位\\n \\\"modifier\\\": \\\"\\\",\\n // 可选,单位位置,合法值:left(左)/ right(右),默认 left\\n \\\"modifierPosition\\\": \\\"left\\\",\\n // 可选,目标方向,合法值:increase(越大越好)/ decrease(越小越好),影响进度条配色逻辑\\n \\\"targetDirection\\\": \\\"increase\\\",\\n\\n // ── 日期进度(dataType = \\\"date\\\")专属配置 ────────────────────────────────\\n // 目标日期范围,合法值:month(本月)/ quarter(本季度)/ year(今年)/ custom(自定义)\\n \\\"targetDateSelected\\\": \\\"month\\\",\\n // targetDateSelected = \\\"custom\\\" 时填写,自定义目标日期范围 [开始时间戳, 结束时间戳]\\n \\\"targetDateRange\\\": null,\\n // 当前日期,合法值:today(今日)/ yesterday(昨日)/ custom(自定义)\\n \\\"currentDateSelected\\\": \\\"today\\\",\\n // currentDateSelected = \\\"custom\\\" 时填写,自定义当前日期时间戳\\n \\\"currentDate\\\": null,\\n // 日期格式,如 \\\"YYYY-MM-DD\\\",仅在 targetDateSelected 或 currentDateSelected 为 custom 时生效\\n \\\"dateFormat\\\": \\\"YYYY-MM-DD\\\",\\n\\n // ── 样式配置 ──────────────────────────────────────────────────────────────\\n // 可选,卡片背景颜色,十六进制色值,如 \\\"#F0F5FF\\\"\\n \\\"background\\\": \\\"\\\",\\n // 可选,百分比数字颜色,十六进制色值,如 \\\"#1677FF\\\"\\n \\\"percentageColor\\\": \\\"\\\",\\n // 可选,进度条填充颜色,支持两种形式:\\n // 1. 固定色值字符串,如 \\\"#1677FF\\\",或渐变色 \\\"linear-gradient(...)\\\"(圆环图支持渐变)\\n // 2. 条件配色对象(根据百分比区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\\n \\\"strokeColor\\\": \\\"\\\",\\n // 可选,进度条背景颜色\\n \\\"strokeBgColor\\\": \\\"\\\",\\n // 可选,当前值文字颜色(仅 dataType=number 时生效)\\n \\\"currentColor\\\": \\\"\\\",\\n // 可选,目标值文字颜色\\n \\\"targetColor\\\": \\\"\\\"\\n },\\n// 查询页配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"QUERY_PAGE\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"QUERY_PAGE\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"查询页\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"\\\",\\n // 查询页无其他特殊配置字段,\\n // 主要用于提供数据查询和展示的基础功能\\n },\\n// 雷达图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"RADAR\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"RADAR\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"雷达图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"xSortBy\\\": \\\"y\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"radarShape\\\": \\\"circle\\\",\\n \\\"isFilled\\\": true,\\n \\\"gridShow\\\": true,\\n \\\"yAxisLine\\\": true,\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"label\\\": true,\\n \\\"filter\\\": []\\n },\\n// 富文本配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"RICH_TEXT\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"RICH_TEXT\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"富文本\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 可选,富文本内容,支持 HTML 格式\\n \\\"content\\\": \\\"\\\"\\n },\\n// 玫瑰图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"ROSE\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"ROSE\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"玫瑰图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"none\\\",\\n \\\"label\\\": true,\\n \\\"innerRadius\\\": 0,\\n \\\"outerRadius\\\": 60,\\n \\\"filter\\\": []\\n },\\n// 散点图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"SCATTER\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"SCATTER\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"散点图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_02\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"smooth\\\": false,\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"gridShow\\\": false,\\n \\\"label\\\": false,\\n \\\"xAxisShow\\\": true,\\n \\\"xAxisLine\\\": false,\\n \\\"xAxisLabel\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"yAxisLine\\\": false,\\n \\\"yAxisLabel\\\": true,\\n \\\"filter\\\": []\\n },\\n// 筛选器配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"SLICER\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"SLICER\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"筛选器\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 可选,筛选器样式,合法值有:button(按钮)/ dropdown(下拉),默认为 button\\n \\\"sli_filterStyle\\\": \\\"button\\\",\\n // 可选,排序方式,合法值有:asc(升序)/ desc(降序),默认为 asc\\n \\\"sli_orderBy\\\": \\\"asc\\\",\\n // 可选,是否多选,默认为 true\\n \\\"sli_multiple\\\": true,\\n // 可选,是否显示数量,默认为 true\\n \\\"sli_showCount\\\": true,\\n // 可选,日期操作符,合法值有:BEFORE / AFTER / BETWEEN 等\\n \\\"sli_dateOperator\\\": \\\"BEFORE\\\",\\n // 可选,数字操作符,合法值有:EQ(等于)/ NE(不等于)/ GT(大于)等\\n \\\"sli_numberOperator\\\": \\\"EQ\\\",\\n // 可选,关联到图表的方式,合法值有:auto(自动)/ manual(手动),默认为 auto\\n \\\"sli_2Chart\\\": \\\"auto\\\",\\n // 可选,关联到其他筛选器的方式,合法值有:auto(自动)/ manual(手动),默认为 auto\\n \\\"sli_2Slicer\\\": \\\"auto\\\",\\n // 可选,对齐方式,合法值有:top(顶部)/ bottom(底部),默认为 top\\n \\\"sli_align\\\": \\\"top\\\",\\n // 可选,选择样式,合法值有:1 / 2 / 3 等,默认为 1\\n \\\"sli_selectStyle\\\": \\\"1\\\",\\n // 必填,绑定的字段 ID(必须是该表中真实存在的字段 ID)\\n \\\"sli_fieldId\\\": \\\"fld_select_01\\\"\\n },\\n// 堆叠面积图配置格式\\n \\\"STACKED_AREA\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"STACKED_AREA\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"堆叠面积图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_date_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\"\\n }\\n ],\\n \\\"smooth\\\": false,\\n \\\"symbolType\\\": \\\"always\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"label\\\": false,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"filter\\\": []\\n }\\n// 堆叠条形图配置格式\\n \\\"STACKED_BAR\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"STACKED_BAR\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"堆叠条形图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_02\\\"\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"bottom\\\",\\n \\\"label\\\": false,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"categoryAxisMaxRatio\\\": \\\"0.3\\\",\\n \\\"filter\\\": []\\n }\\n// 堆叠柱状图配置格式\\n \\\"STACKED_HISTOGRAM\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"STACKED_HISTOGRAM\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"堆叠柱状图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_date_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\"\\n }\\n ],\\n \\\"xSortBy\\\": \\\"x\\\",\\n \\\"sortRule\\\": \\\"asc\\\",\\n \\\"nullValue\\\": \\\"ignore\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"bottom\\\",\\n \\\"gridShow\\\": false,\\n \\\"label\\\": false,\\n \\\"labelHideOverlap\\\": true,\\n \\\"xAxisShow\\\": true,\\n \\\"xAxisLine\\\": false,\\n \\\"xAxisLabel\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"yAxisLine\\\": false,\\n \\\"yAxisLabel\\\": true,\\n \\\"fillType\\\": \\\"solid\\\",\\n \\\"filter\\\": []\\n }\\n// 堆叠折线图配置格式\\n \\\"STACKED_LINE\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"STACKED_LINE\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"堆叠折线图\\\",\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_date_01\\\",\\n \\\"externalValue\\\": []\\n }\\n ],\\n \\\"group\\\": [\\n {\\n \\\"value\\\": \\\"fld_select_01\\\"\\n }\\n ],\\n \\\"smooth\\\": false,\\n \\\"symbolType\\\": \\\"always\\\",\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"legend\\\": \\\"top\\\",\\n \\\"label\\\": false,\\n \\\"xAxisShow\\\": true,\\n \\\"yAxisShow\\\": true,\\n \\\"filter\\\": []\\n }\\n// 标准列表视图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"STANDARD_LIST_VIEW\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"STANDARD_LIST_VIEW\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"标准列表视图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId\\n \\\"view\\\": \\\"view_001\\\",\\n // 无 filter 时请传空数组,\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置) 中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 视图排序配置,用于设置视图的默认排序规则\\n \\\"viewSort\\\": [],\\n // 隐藏字段配置,key 为 fieldId,value 为 true/false\\n \\\"hiddenFields\\\": {},\\n // 列排序配置,用于设置列的显示顺序\\n \\\"columnSort\\\": [],\\n // 列表自定义配置,用于设置列表的自定义样式和行为\\n \\\"listCustom\\\": {},\\n // 列表配置,用于设置列表的显示方式和布局\\n \\\"listConfig\\\": {},\\n // 字段聚合映射,用于设置字段的聚合方式\\n \\\"fieldAggregateMap\\\": {},\\n // 工具栏配置,用于控制标准列表视图中工具栏的功能按钮\\n \\\"toolbarConfig\\\": {\\n // 是否显示创建行按钮\\n \\\"enableCreateRow\\\": false,\\n // 是否显示筛选按钮\\n \\\"enableFilter\\\": true,\\n // 是否显示分组按钮\\n \\\"enableGroup\\\": true,\\n // 是否显示排序按钮\\n \\\"enableSort\\\": true,\\n // 是否显示查找按钮\\n \\\"enableFind\\\": true,\\n // 是否显示详情记录按钮\\n \\\"enableDetailRecord\\\": true,\\n // 是否显示编辑记录按钮\\n \\\"enableEditRecord\\\": false\\n },\\n },\\n// ═══════════════════════════════════════════════════════════════════\\n // 图表类型:指标卡(STATISTICS)\\n // 别名/同义词:KPI 卡、指标图、数据指标卡、关键指标卡\\n // 用途:以大号数字展示核心指标,并可附加环同比(与上期对比的涨跌幅)\\n // 和趋势折线图,适合展示销售额、DAU、转化率等需要对比分析的 KPI 指标。\\n // 与\\\"统计数字(SUMMARY)\\\"的区别:指标卡支持环同比对比和趋势图,\\n // 功能更丰富;统计数字仅展示单一数字,更简洁。\\n // 重要:当用户说\\\"指标卡\\\"、\\\"KPI 卡\\\"、\\\"指标图\\\"、\\\"关键指标\\\"、\\\"带趋势的数字\\\"时,\\n // 必须使用 chartType = \\\"STATISTICS\\\",不要使用其他图表类型。\\n // ═══════════════════════════════════════════════════════════════════\\n \\\"STATISTICS\\\": {\\n // 必填,图表类型,固定值 \\\"STATISTICS\\\"(指标卡)\\n \\\"chartType\\\": \\\"STATISTICS\\\",\\n // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\\n \\\"name\\\": \\\"指标卡\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时为 null,传入时必须是该表中真实存在的 viewId\\n \\\"view\\\": null,\\n // 无 filter 时请传空数组;\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置)中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 统计指标配置,measureType 与 measure 二选一:\\n // measureType = \\\"record-count\\\" 时统计记录总数,无需填 measure;\\n // measureType = \\\"field\\\" 时需填 measure,指定聚合字段和聚合函数\\n \\\"measureType\\\": \\\"field\\\",\\n // 可选,当 measureType = \\\"field\\\" 时必填,指定聚合字段\\n \\\"measure\\\": [\\n {\\n // 必填,字段 ID(必须是该表中真实存在的数值类字段 ID)\\n \\\"value\\\": \\\"fld_number_01\\\",\\n // 必填,聚合函数配置\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n // formula 合法值:count (计数), unique-count (去重计数), sum (求和), average (平均值), max (最大值), min (最小值)'\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n // 可选,指标说明文字,显示在数值下方\\n \\\"desc\\\": \\\"\\\",\\n // 可选,数字格式化配置\\n \\\"numberFormat\\\": {\\n // 小数位数,默认 0\\n \\\"digits\\\": 0,\\n // 格式类型,合法值:number / percent / thousand\\n \\\"format\\\": \\\"number\\\",\\n // 是否显示千位分隔符\\n \\\"thousandSeparator\\\": true\\n },\\n // 可选,是否显示环同比,默认 false\\n \\\"showComparison\\\": false,\\n // 可选,showComparison=true 时填写,环同比对比的日期字段 ID\\n \\\"comparisonField\\\": null,\\n // 可选,环同比时间范围,如 \\\"this_week\\\" / \\\"this_month\\\" / \\\"this_year\\\"\\n \\\"comparisonDateRange\\\": null,\\n // 可选,环同比类型,合法值:sequential(环比)/ year-on-year(同比)/ month-on-month(月比)/ week-on-week(周比)/ custom(自定义)\\n \\\"comparisonType\\\": \\\"sequential\\\",\\n // 可选,时间统计方式,合法值:same_period(同期)/ cumulative(累计)\\n \\\"comparisonPeriodType\\\": \\\"same_period\\\",\\n // 可选,环同比计算类型,合法值:difference-rate(差值率)/ difference-value(差值)/ original-value(原值)\\n \\\"comparisonCalculationType\\\": \\\"difference-rate\\\",\\n // 可选,环同比布局方向(仅 showComparison=true 且 showTrend=false 时生效),合法值:bottom(下方)/ right(右侧)\\n \\\"comparisonLayout\\\": \\\"bottom\\\",\\n // 可选,是否显示趋势图,默认 false\\n \\\"showTrend\\\": false,\\n // 可选,showTrend=true 时填写,趋势图使用的日期字段 ID\\n \\\"trendField\\\": null,\\n // 可选,趋势图时间范围,如 \\\"this_week\\\" / \\\"this_month\\\" / \\\"this_year\\\"\\n \\\"trendDateRange\\\": null,\\n // 可选,趋势图布局方向(仅 showTrend=true 且 showComparison=false 时生效),合法值:bottom(下方)/ right(右侧)\\n \\\"trendLayout\\\": \\\"bottom\\\",\\n // 可选,趋势图颜色,十六进制色值,如 \\\"#1677FF\\\"\\n \\\"trendColor\\\": \\\"\\\",\\n // 可选,环同比图标样式,合法值:1 / 2 / 3 / 4 / 5 / 6 / 7 / 8\\n \\\"iconStyle\\\": \\\"5\\\",\\n // 可选,卡片标题颜色,十六进制色值\\n \\\"titleColor\\\": \\\"\\\",\\n // 可选,指标说明文字颜色,十六进制色值\\n \\\"descColor\\\": \\\"\\\",\\n // 可选,环同比描述颜色,十六进制色值\\n \\\"comparisonDescColor\\\": \\\"\\\",\\n // 可选,数值颜色配置,支持两种形式:\\n // 1. 固定色值字符串,如 \\\"#1677FF\\\",或渐变色 \\\"linear-gradient(...)\\\"\\n // 2. 条件配色对象(根据数值区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\\n \\\"colors\\\": \\\"\\\"\\n },\\n// ═══════════════════════════════════════════════════════════════════\\n // 图表类型:统计数字(SUMMARY)\\n // 别名/同义词:汇总数字、统计卡、数字卡、计数卡\\n // 用途:以大号数字形式展示单一聚合指标(如记录总数、字段求和/均值等),\\n // 是最简洁的数据汇总展示方式,适合展示总销售额、总订单数、平均分等。\\n // 与其他图表的关键区别:\\n // - 与\\\"指标卡(STATISTICS)\\\"的区别:统计数字更简洁,无环同比、无趋势图;\\n // 指标卡功能更丰富,支持环同比对比和趋势折线图。\\n // - 与\\\"柱状图(HISTOGRAM)\\\"的关键区别:统计数字只展示一个聚合数字(单值卡片),\\n // 无坐标轴、无分类对比;柱状图展示多个分类的数值对比,有 X/Y 轴(多值图表)。\\n // 重要:当用户说\\\"统计数字\\\"、\\\"汇总数字\\\"、\\\"数字卡\\\"、\\\"计数\\\"、\\\"总数\\\"时,\\n // 必须使用 chartType = \\\"SUMMARY\\\",不要使用 HISTOGRAM 或其他图表类型。\\n // ═══════════════════════════════════════════════════════════════════\\n \\\"SUMMARY\\\": {\\n // 必填,图表类型,固定值 \\\"SUMMARY\\\"(统计数字)\\n \\\"chartType\\\": \\\"SUMMARY\\\",\\n // 必填,卡片标题,用户在仪表盘中看到的图表名称,应准确且见名知意\\n \\\"name\\\": \\\"统计数字\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时为 null,传入时必须是该表中真实存在的 viewId\\n \\\"view\\\": null,\\n // 无 filter 时请传空数组;\\n // 如果传入 filter,可参考 dashboard-config-example(仪表盘示例配置)中 filters 的 filter 格式,\\n // 注意 filter 中的 fieldId 必须是该表中真实存在的字段 ID\\n \\\"filter\\\": [],\\n // 统计指标配置,measureType 与 measure 二选一:\\n // measureType = \\\"record-count\\\" 时统计记录总数,无需填 measure;\\n // measureType = \\\"field\\\" 时需填 measure,指定聚合字段和聚合函数\\n \\\"measureType\\\": \\\"field\\\",\\n // 可选,当 measureType = \\\"field\\\" 时必填,指定聚合字段\\n \\\"measure\\\": [\\n {\\n // 必填,字段 ID(必须是该表中真实存在的数值类字段 ID)\\n \\\"value\\\": \\\"fld_number_01\\\",\\n // 必填,聚合函数配置\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n // formula 合法值:count (计数), unique-count (去重计数), sum (求和), average (平均值), max (最大值), min (最小值)'\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n // 可选,数字格式化配置\\n \\\"numberFormat\\\": {\\n // 小数位数,默认 0\\n \\\"digits\\\": 0,\\n // 格式类型,合法值:number / percent / thousand\\n \\\"format\\\": \\\"number\\\",\\n // 是否显示千位分隔符\\n \\\"thousandSeparator\\\": true\\n },\\n // 可选,指标说明文字,由用户在编辑器中输入,传给服务端后显示在数值下方\\n \\\"desc\\\": \\\"\\\",\\n // 可选,数值颜色配置,支持两种形式:\\n // 1. 固定色值字符串,如 \\\"#1677FF\\\",或渐变色 \\\"linear-gradient(...)\\\"\\n // 2. 条件配色对象(根据数值区间动态变色),格式参考 dashboard-config-example 中的 colors 配置\\n \\\"colors\\\": \\\"\\\"\\n },\\n// 标签页配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"TABS\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"TABS\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"标签页\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n // 可选,标签页样式类型,合法值有:type1 到 type10,默认为 type1\\n \\\"tabsStyleType\\\": \\\"type1\\\",\\n // 可选,标签页对齐方式,合法值有:left(左对齐)/ center(居中)/ right(右对齐),默认为 left\\n \\\"tabsAlignType\\\": \\\"left\\\",\\n // 可选,默认激活的标签页 ID,必须与 tabs 数组中的某个 id 匹配\\n \\\"activeTabId\\\": \\\"tab_001\\\",\\n // 必填,标签页数组\\n \\\"tabs\\\": [\\n {\\n // 必填,标签页 ID(必须唯一)\\n \\\"id\\\": \\\"tab_001\\\",\\n // 必填,标签页名称\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"标签页 1\\\"\\n },\\n {\\n \\\"id\\\": \\\"tab_002\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"标签页 2\\\"\\n },\\n {\\n \\\"id\\\": \\\"tab_003\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"标签页 3\\\"\\n }\\n ]\\n },\\n// 词云图配置格式,必须按照此格式配置,否则会导致解析错误\\n \\\"WORD_CLOUD\\\": {\\n // 必填,图表的类型,不传或传错图表将渲染异常\\n \\\"chartType\\\": \\\"WORD_CLOUD\\\",\\n // 必填,图表的名称,是仪表盘中用户看到的图表卡片的标题,应该准确且见名知意\\n \\\"name\\\": \\\"词云图\\\",\\n // 必填,数据表 ID(必须是真实存在的 tableId),可通过 get_base 获取\\n \\\"sheet\\\": \\\"sheet_001\\\",\\n // 可选,视图 ID,不传时直接 null 或 undefined,如果传 viewId 必须是真实存在的 viewId)\\n \\\"view\\\": \\\"view_001\\\",\\n \\\"measureType\\\": \\\"field\\\",\\n \\\"measure\\\": [\\n {\\n \\\"value\\\": \\\"fld_number_01\\\",\\n \\\"externalValue\\\": [\\n {\\n \\\"type\\\": \\\"formula\\\",\\n \\\"value\\\": \\\"sum\\\"\\n }\\n ]\\n }\\n ],\\n \\\"dimension\\\": [\\n {\\n \\\"value\\\": \\\"fld_text_01\\\"\\n }\\n ],\\n \\\"disableParticiple\\\": false, // 是否禁用分词\\n \\\"colors\\\": \\\"COLOR_PALETTE_1\\\",\\n \\\"filter\\\": []\\n },\\n}\\n\",\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Dashboard widgets config example returned. Each key is a chart type (e.g. HISTOGRAM, PIE), and the value is the corresponding widget config example. Use the matching chart type's config as a reference when calling create_chart.\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1286 - }, - { - "service": "aitable", - "command": "+dashboard-config-example", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +dashboard-config-example --yes --format json", - "args": [ - "aitable", - "+dashboard-config-example", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": \"// 注意\\n// 1. 下述 JSON 示例中的注释(// 开头的注释)仅用于说明,实际使用时请删除,否则会导致解析错误\\n// 2. 当一个仪表盘创建完成,并添加图表完成后,检查一下布局,如果有空白或不对齐的图表,请调用 update_chart 优化布局\\n// 3. 当一个仪表盘完整创建后,包括图表也添加完成后,可通过 get_dashboard 接口查看仪表盘详情,检查布局是合适(没有空白或不对齐),如果有问题,调用 update_chart 优化布局\\n// 4. 仪表盘创建好并优化布局后,向用户输出仪表盘链接 URL(注意是仪表盘 URL),方便用户直接访问和使用仪表盘\\n\\n{\\n // 服务端自动生成,调用方传入会被忽略\\n \\\"id\\\": \\\"dashboard_001\\\",\\n // 必填,dashboard 名称,是整个仪表盘的标题,一定要传入,且清晰明确,方便用户使用\\n \\\"name\\\": \\\"示例仪表盘\\\",\\n // 必须使用本示例中的默认主题,禁止自行编造,随意生成的主题无法解析和渲染\\n \\\"theme\\\": {\\n // 必须为 DEFAULT\\n \\\"key\\\": \\\"DEFAULT\\\",\\n // 主题背景色,大部分情况下「传入 null 或 undefined」,使用默认即可\\n \\\"background\\\": null,\\n \\\"image\\\": \\\"\\\"\\n },\\n // 可选,全局筛选条件数组;每个元素对应一张数据表的筛选规则\\n \\\"filters\\\": [\\n {\\n // 要筛选的数据表 ID(必须是真实存在的 tableId)\\n \\\"sheetId\\\": \\\"sheet_001\\\",\\n // 筛选条件数组;第一个条件的 link 必须为 null,后续条件用 \\\"AND\\\" 或 \\\"OR\\\" 连接\\n \\\"filter\\\": [\\n {\\n // 要筛选的字段 ID(必须是该表中真实存在的 fieldId)\\n \\\"fieldId\\\": \\\"fld_001\\\",\\n // 条件连接符:第一个条件必须为 null,后续条件为 \\\"AND\\\" 或 \\\"OR\\\"\\n \\\"link\\\": null,\\n // 固定为 1\\n \\\"version\\\": 1,\\n // 比较符号,合法值按字段类型分组如下:\\n // 文本类: EQ(等于), NE(不等于), CONTAIN(包含), EXCLUSIVE(不包含) — value 为字符串\\n // 数字类: EQ, NE, GT(大于), LT(小于), GTE(大于等于), LTE(小于等于) — value 为数字\\n // 日期类: DATE_EQ(日期等于), BEFORE(早于), AFTER(晚于), NOT_AFTER(不晚于), NOT_BEFORE(不早于) — value 为 \\\"yyyy-MM-dd\\\" 格式字符串\\n // 选项类: ANY_OF(包含任一), ALL_OF(包含全部), NONE_OF(不包含任一) — value 为选项 ID 字符串数组,如 [\\\"opt_001\\\", \\\"opt_002\\\"]\\n // 存在性: EXIST(有值), UN_EXIST(无值) — value 为 null\\n \\\"symbol\\\": \\\"EQ\\\",\\n // value 的类型取决于 symbol,参见上方说明\\n \\\"value\\\": \\\"已完成\\\"\\n }\\n ]\\n }\\n ],\\n // 图表布局数组;创建时会被忽略(默认空数组),更新时有效\\n // 每个元素描述一个图表在网格中的位置和尺寸\\n // layout 的更新,需要通过 update_chart 接口更新到每个图表上,update_chart 接口会自动根据 layout 调整仪表盘布局\\n // 无法通过 update_dashboard 接口直接更新布局,必须通过 update_chart 接口更新图表来间接更新布局\\n \\\"layout\\\": [\\n {\\n // 对应的 widget/chart ID\\n \\\"i\\\": \\\"widget_001\\\",\\n // 横坐标(列位置,从 0 开始,取值范围 0~11)\\n \\\"x\\\": 0,\\n // 纵坐标(行位置,从 0 开始,无上限)\\n \\\"y\\\": 0,\\n // 宽度(占几列,网格总宽 12 列,取值范围 1~12,同一行所有图表的 w 之和应等于 12)\\n \\\"w\\\": 12,\\n // 高度(占几行,取值范围 1~N,同一行的图表必须保持相同高度)\\n \\\"h\\\": 6,\\n // 最小宽度(可选,限制用户拖拽缩放的下限)\\n \\\"minW\\\": 4,\\n // 最小高度(可选,限制用户拖拽缩放的下限)\\n \\\"minH\\\": 3,\\n // 可选,所属分组 ID(用于 COMPOSITE_LAYOUT 组合布局,不使用分组时省略)\\n \\\"parentId\\\": \\\"group_001\\\"\\n }\\n ]\\n}\",\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Dashboard config example returned. The 'id' field is for structure reference only; it will be auto-generated by the server when creating a dashboard. The 'layout' field is ignored when creating (defaults to empty array), but effective when updating.\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1064 - }, - { - "service": "aitable", - "command": "+dashboard-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +dashboard-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --yes --format json", - "args": [ - "aitable", - "+dashboard-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--dashboard-id", - "KY9tlWg5NEHgfs8WT6IO2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartCount\": 8,\n \"charts\": [\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-dlxFo0tNSImp4ITpDn5fQ\",\n \"chartName\": \"各业务线未部署数\",\n \"chartType\": \"BAR\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-OQW55ZoOPT3abjYxQbdBo\",\n \"chartName\": \"部署完成率\",\n \"chartType\": \"PIE\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"0ed6ae150c7e43508d504b741ca1b19a\",\n \"chartName\": \"设备付费情况分布\",\n \"chartType\": \"PIE\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-dqjTHaYZdhbSTwRk3JEjI\",\n \"chartName\": \"领取人未部署排行\",\n \"chartType\": \"LEADERBOARD\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-qqMeLF69BEVnKCglPUPRw\",\n \"chartName\": \"验证情况分析\",\n \"chartType\": \"BAR\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-tIkpYHZqciaEu0ypokSEt\",\n \"chartName\": \"部署时间趋势\",\n \"chartType\": \"LINE\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-PcnRUOqW5RzJHFZ1BxQJe\",\n \"chartName\": \"客户未部署分布\",\n \"chartType\": \"HISTOGRAM\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n },\n {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"chartId\": \"widget-8LICblz3EqyoLbkyM22Bw\",\n \"chartName\": \"付费状态漏斗\",\n \"chartType\": \"FUNNEL\",\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\"\n }\n ],\n \"dashboardId\": \"KY9tlWg5NEHgfs8WT6IO2\",\n \"dashboardName\": \"仪表盘\",\n \"filters\": [],\n \"layout\": [\n {\n \"h\": 4,\n \"i\": \"widget-05w7XiwviqQgWrMxmvP20\",\n \"w\": 12,\n \"x\": 0,\n \"y\": 0\n },\n {\n \"h\": 5,\n \"i\": \"widget-dlxFo0tNSImp4ITpDn5fQ\",\n \"w\": 6,\n \"x\": 0,\n \"y\": 4\n },\n {\n \"h\": 5,\n \"i\": \"widget-PcnRUOqW5RzJHFZ1BxQJe\",\n \"w\": 6,\n \"x\": 6,\n \"y\": 4\n },\n {\n \"h\": 5,\n \"i\": \"widget-qqMeLF69BEVnKCglPUPRw\",\n \"w\": 6,\n \"x\": 0,\n \"y\": 9\n },\n {\n \"h\": 5,\n \"i\": \"widget-tIkpYHZqciaEu0ypokSEt\",\n \"w\": 6,\n \"x\": 6,\n \"y\": 9\n },\n {\n \"h\": 5,\n \"i\": \"widget-OQW55ZoOPT3abjYxQbdBo\",\n \"w\": 4,\n \"x\": 0,\n \"y\": 14\n },\n {\n \"h\": 5,\n \"i\": \"widget-dqjTHaYZdhbSTwRk3JEjI\",\n \"w\": 4,\n \"x\": 4,\n \"y\": 14\n },\n {\n \"h\": 5,\n \"i\": \"widget-8LICblz3EqyoLbkyM22Bw\",\n \"w\": 4,\n \"x\": 8,\n \"y\": 14\n },\n {\n \"h\": 4,\n \"i\": \"0ed6ae150c7e43508d504b741ca1b19a\",\n \"w\": 6,\n \"x\": 0,\n \"y\": 0\n }\n ],\n \"meta\": {}\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Got dashboard KY9tlWg5NEHgfs8WT6IO2. URL rules: AI 表格仪表盘访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=applicationId%3D{dashboardId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1179 - }, - { - "service": "aitable", - "command": "+field-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +field-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --field-ids 01ZM8y7 --yes --format json", - "args": [ - "aitable", - "+field-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--field-ids", - "01ZM8y7", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"fields\": [\n {\n \"aiConfig\": {\n \"autoRecompute\": false,\n \"computeOnEmptyRef\": false,\n \"enableThinking\": false,\n \"enableWebSearch\": false,\n \"imageConfig\": {\n \"aiGeneratedWatermark\": true,\n \"count\": 1,\n \"resolution\": \"1024*1024\"\n },\n \"outputType\": \"text\",\n \"prompt\": [\n {\n \"type\": \"text\",\n \"value\": \"你是一位贴心的文字识别助手,当用户需要识别图片中的文字时(如菜单、传单、说明书),清晰识别出内容。\\n\\n执行前严格验证输入:\\n- 如果未提供图片,立即回复:\\\"请上传需要识别文字的图片,我帮您快速查看内容!\\\"\\n- 如果图片模糊/黑暗,回复:\\\"图片不太清楚,建议:1) 重新拍摄 2) 对准文字区域 3) 确保光线充足\\\"\\n当输入有效时,按以下规则生成结果(禁用任何技术格式):\\n1. 识别原则(像真人一样读图)\\n - 严格按阅读顺序朗读文字,保留原换行与空格\\n - 模糊处标注\\\"(看不清)\\\",不猜测内容(例:\\\"价(格)\\\")\\n - 保留所有标点、符号和大小写(如\\\"¥25\\\"不改为\\\"25元\\\")\\n - 表格按\\\"从左到右、从上到下\\\"顺序朗读\\n2. 输出格式要求(必须遵守)\\n - 禁用JSON/XML/代码块,仅用**模拟纸质文档**的直观格式\\n - 示例(基于用户输入):\\n 欢迎光临\\n ABC咖啡店\\n 今日特惠:\\n 拿铁 ¥25\\n 卡布奇诺 ¥28\\n 营业时间:8:00-22:00\\n3. 关键控制规则\\n - 排版还原:用空行分隔段落,保留项目符号(如\\\"•\\\")\\n - 模糊处理:单个模糊字符→\\\"(看不清)\\\",连续模糊→\\\"(此处有文字但看不清)\\\"\\n - 语言提示:仅列出主要语言(如\\\"中文为主,含少量英文\\\")\\n - 空结果处理:写\\\"未检测到清晰文字,请检查图片\\\"\\n - 绝对禁止:不添加任何解释(如\\\"根据OCR技术...\\\")\\n4. 禁止行为\\n × 生成任何JSON/XML代码 \\n × 使用术语如\\\"full_text\\\"、\\\"lines\\\" \\n × 修改原始文字内容(如\\\"¥25\\\"不改为\\\"25元\\\")\\n\\n下面是你要总结和分析的原始内容\"\n },\n {\n \"fieldId\": \"2hfk5Hd\",\n \"type\": \"fieldRef\"\n },\n {\n \"type\": \"text\",\n \"value\": \",只保留识别结果中的“序列号”或者“Serial No\\\"\"\n }\n ],\n \"type\": \"ai-agent\"\n },\n \"fieldId\": \"01ZM8y7\",\n \"fieldName\": \"序列号\",\n \"type\": \"text\"\n }\n ]\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Got 1 fields from table hERWDMS\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1402 - }, - { - "service": "aitable", - "command": "+find-record", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +find-record --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table hERWDMS --query '测试' --yes --format json", - "args": [ - "aitable", - "+find-record", - "--base", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table", - "hERWDMS", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"nextCursor\": \"oXh7WgGooe\",\n \"records\": [\n {\n \"cells\": {\n \"01ZM8y7\": \"N0QWY69QN4\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4048.HEIC.JPG\",\n \"resourceId\": \"479e4321-3d6a-42f0-91de-583c6ea41d9f\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5268e3736ebad509e7fcef9dcd108f495a911ef939606b63dc103944eb9062bae7f23148f19bc87f4\",\n \"size\": 1753393,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"8LVOssPz6Q\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"CDQD7CN97V\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4054.HEIC.JPG\",\n \"resourceId\": \"7d68adf9-ec88-4192-8728-5de240f17fc8\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d57cd5cd33059ea9b3d2766775dd1ba8096b8c5a9cb3defd6f5321c276624c51f602fba57aca34bc6d\",\n \"size\": 1801325,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\\nAI营销借用\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"133206\"\n }\n ]\n },\n \"recordId\": \"AjOk0hu1pe\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"RY7LCW719Q\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4213.HEIC.JPG\",\n \"resourceId\": \"0022f2cc-bd93-49f0-8039-04ac2909dd32\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d59a2750fefa807e0cfc41158e98407c864f1745537f406fcb0e02c76b1696b84f8bac5e1298eba711\",\n \"size\": 1763574,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"DUDacJdQ2X\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"D54X6C7WH6\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"image.png\",\n \"resourceId\": \"e7dea77f-0f65-4266-a3fd-e401b80a9582\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5d45d39275c65656906af57b60be7002feaf431efea862523554a56545a2e6ac55d17ee7f4c9382c9\",\n \"size\": 9795238,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ],\n \"ty1M3J4\": {\n \"markdown\": \"已归还\\n\"\n }\n },\n \"recordId\": \"Dt8KZX7Nt2\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"MN7V04VVD4\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4044.HEIC.JPG\",\n \"resourceId\": \"82903e20-99ca-4497-8596-64b8a9511347\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5585fe6169387aa3767c7e8d94f1e6b2bdcb910b90a359120b13c8f9d1a59101157155a75536e1183\",\n \"size\": 1965625,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"FA0TZMthqI\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"CYY9PQRXJ5\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4216.HEIC.JPG\",\n \"resourceId\": \"3dfd0bec-a06a-4580-a9a2-b8ae450df2a4\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d52f04469c6bb09596caffbbac393c775d8db27175749aef18fb6444ef163ea9d17fb0a232eda6495f\",\n \"size\": 2290335,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"IyGa4g01n1\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"K30F574N0W\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4071.HEIC.JPG\",\n \"resourceId\": \"8f970cbb-9e4d-4abb-bc2e-bae127a6231a\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5988e57da667e94ed89271196611e117503f232ffda40509efa8b2836f86122df894b0282d28e9bd0\",\n \"size\": 1883600,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"UJwTYA3uNw\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"Y7CXVJ9XWD\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4072.HEIC.JPG\",\n \"resourceId\": \"68b711bd-e972-445a-be79-bf4954b52000\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d581be5bba89215dfa8bf27c7a750a77959fc36654d351427344ca8cee273282f51f530a2b9381f2f5\",\n \"size\": 1856927,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"Uygc2paXj3\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"GMQW2JLWVY\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4214.HEIC.JPG\",\n \"resourceId\": \"ca4deb91-17d6-43e7-9812-72101643deb9\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d57860f934d7f4ae4fd1379962b8f251e8357d71880d1389e2b613f13514ac39fcfcb2a6a1fcb68a65\",\n \"size\": 1640058,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"ewj09NwU1z\",\n \"name\": \"AI招聘\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"429676\"\n }\n ]\n },\n \"recordId\": \"Xjj4e7GgG8\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"Y0V7Y2N2YD\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4074.HEIC.JPG\",\n \"resourceId\": \"5b8d3006-280e-41af-a4b0-3320e6a5d596\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5f3f03272cd3d6f49910031a2e873f763b035f6f21d03abba7ab038475a5ab532635226d96df6a64e\",\n \"size\": 1964512,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"Y056lsOG61\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"P24GFQN7YT\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4033.HEIC.JPG\",\n \"resourceId\": \"38656067-b7b7-45fd-8bea-64f4c89dc4f7\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d50c8666278c495a44ef70265cd8adb7f504b841c8f61607d92f11852026cd92eff88db6bf5a5d630c\",\n \"size\": 2028953,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"YVaPzcuGBa\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"N7Q33L77WR\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"image.png\",\n \"resourceId\": \"4abf67c0-ad03-4a81-a3db-3794d15453e9\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d53aa03710ddc5973e4df2b4d6c72f04177dd7b5a30206f588777548a9d514bbd2a1bdc9489688a5fb\",\n \"size\": 9870487,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"hXTMF9el7v\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"HFk9Y9TYQD\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4043.HEIC.JPG\",\n \"resourceId\": \"d6786a91-bca7-4c09-8225-9503cb66610f\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5fe8628219f060fbe50542a5616689036033b038792e8d5a77e12f7fc37630879073a357b1dacf1ae\",\n \"size\": 2110186,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试\",\n \"6B78kgr\": \"2\",\n \"ARoUEWW\": {\n \"id\": \"P6GYJKc3t0\",\n \"name\": \"未付费\"\n },\n \"Str3hFF\": {\n \"id\": \"m4Y2qZ07Yk\",\n \"name\": \"领取两台\"\n },\n \"ZoCsF67\": \"2026-02-09T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"lpH5zwRv5D\",\n \"name\": \"AI差旅\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"151705\"\n }\n ]\n },\n \"recordId\": \"kk7eQt1kJB\"\n },\n {\n \"cells\": {\n \"01ZM8y7\": \"TX6377KGFH\",\n \"2hfk5Hd\": [\n {\n \"filename\": \"IMG_4064.HEIC.JPG\",\n \"resourceId\": \"850e1a7f-aa0a-4ba8-9b54-5bc9c66a5a63\",\n \"resourceUrl\": \"/core/api/resources/img/5eecdaf48460cde5e74cc918372d9be934282a6fcde347f775b8339e1c4c24834322b4c2cdd63b4d6b58639871ed7e1ba156a98577f418d5716c6100d05323cbd2151a4b9cf8174b09cf0ac1af356178c4572bfa55a8c065f499293f0f9b00e2\",\n \"size\": 1963615,\n \"type\": \"image\",\n \"url\": \"[REDACTED_OSS_URL]\"\n }\n ],\n \"47bTcjO\": \"内部测试--恒飞\",\n \"ARoUEWW\": {\n \"id\": \"Zw0fFCNl8e\",\n \"name\": \"付费\"\n },\n \"ZoCsF67\": \"2026-01-23T00:00:00+08:00\",\n \"j1Zwe1c\": [\n {\n \"id\": \"s9UKjSzdLg\",\n \"name\": \"业务方自主\"\n }\n ],\n \"kFwb8br\": {\n \"id\": \"1ZiuS5Wlgz\",\n \"name\": \"内部部署\"\n },\n \"rjwzzIu\": [\n {\n \"id\": \"ewj09NwU1z\",\n \"name\": \"AI招聘\"\n }\n ],\n \"t9n9SDE\": [\n {\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"userId\": \"103262\"\n }\n ],\n \"ty1M3J4\": {\n \"markdown\": \"目前看设备离线\\n\"\n }\n },\n \"recordId\": \"oXh7WgGooe\"\n }\n ]\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Successfully queried 14 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2849 - }, - { - "service": "aitable", - "command": "+form-field-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +form-field-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id lmeV1cb --yes --format json", - "args": [ - "aitable", - "+form-field-list", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--view-id", - "lmeV1cb", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"fields\": [\n {\n \"fieldId\": \"t9n9SDE\",\n \"hidden\": false,\n \"name\": \"领取人\",\n \"required\": true,\n \"type\": \"user\"\n },\n {\n \"fieldId\": \"j1Zwe1c\",\n \"hidden\": false,\n \"name\": \"验证情况\",\n \"required\": true,\n \"type\": \"multipleSelect\"\n },\n {\n \"fieldId\": \"2hfk5Hd\",\n \"hidden\": false,\n \"name\": \"商品图片\",\n \"required\": false,\n \"type\": \"file\"\n },\n {\n \"fieldId\": \"ZoCsF67\",\n \"hidden\": false,\n \"name\": \"领取时间\",\n \"required\": true,\n \"type\": \"date\"\n },\n {\n \"fieldId\": \"47bTcjO\",\n \"hidden\": false,\n \"name\": \"交付客户\",\n \"required\": true,\n \"type\": \"text\"\n },\n {\n \"description\": \"填写:客户业务介绍,老板是谁,实际使用部门,使用人数,主要解决客户什么问题,客户原声\",\n \"fieldId\": \"3p4hbFh\",\n \"hidden\": false,\n \"name\": \"客户背景\",\n \"required\": true,\n \"type\": \"richText\"\n }\n ],\n \"tableId\": \"hERWDMS\",\n \"viewId\": \"lmeV1cb\"\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Found 6 field(s) in form view lmeV1cb\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1139 - }, - { - "service": "aitable", - "command": "+form-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +form-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --yes --format json", - "args": [ - "aitable", - "+form-list", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 2,\n \"forms\": [\n {\n \"viewId\": \"lmeV1cb\",\n \"viewName\": \"设备登记\"\n },\n {\n \"viewId\": \"HaFV1LW\",\n \"viewName\": \"客户使用情况\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1003 - }, - { - "service": "aitable", - "command": "+form-share-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +form-share-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id lmeV1cb --yes --format json", - "args": [ - "aitable", - "+form-share-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--view-id", - "lmeV1cb", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"anonymousSubmit\": false,\n \"authTypeCode\": 0,\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"enabled\": true,\n \"formName\": \"Mac Mini领取信息登记\",\n \"loadLastSubmit\": true,\n \"punished\": false,\n \"replyNotice\": false,\n \"shareFormUuid\": \"v01ZWGl05mvrY0PMn34_hERWDMS_lmeV1cb\",\n \"status\": 1,\n \"submitTimesUserLimit\": 0,\n \"tableId\": \"hERWDMS\",\n \"viewId\": \"lmeV1cb\"\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Fetched share form config for view lmeV1cb: enabled=true\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1067 - }, - { - "service": "aitable", - "command": "+list-tables", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +list-tables --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json", - "args": [ - "aitable", - "+list-tables", - "--base", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"tables\": [\n {\n \"tableId\": \"hERWDMS\",\n \"tableName\": \"Mac Mini\"\n },\n {\n \"tableId\": \"k8d898q\",\n \"tableName\": \"手机\"\n },\n {\n \"tableId\": \"zyLRwwa\",\n \"tableName\": \"SIM卡\"\n },\n {\n \"tableId\": \"L53jNZz\",\n \"tableName\": \"机箱\"\n },\n {\n \"tableId\": \"zTiHNDL\",\n \"tableName\": \"磁吸片\"\n },\n {\n \"tableId\": \"anMdihs\",\n \"tableName\": \"手机线\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1452 - }, - { - "service": "aitable", - "command": "+record-history-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +record-history-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --offset 1 --limit 10 --yes --format json", - "args": [ - "aitable", - "+record-history-list", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--record-id", - "1015oH3OXy", - "--offset", - "1", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"histories\": [\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{\\\"47bTcjO\\\":{\\\"dataType\\\":\\\"STRING\\\",\\\"value\\\":\\\"山东梅格彤\\\"}}\",\n \"oldValue\": \"{\\\"47bTcjO\\\":{\\\"dataType\\\":\\\"STRING\\\",\\\"value\\\":\\\"内部测试\\\"}}\",\n \"operateTime\": 1772700326253,\n \"type\": \"row\",\n \"version\": 2455\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{\\\"ARoUEWW\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"Zw0fFCNl8e\\\",\\\"sequence\\\":\\\"Zw0fFCNl8e\\\",\\\"data\\\":\\\"Zw0fFCNl8e\\\",\\\"filterFactor\\\":\\\"Zw0fFCNl8e\\\"}}}\",\n \"oldValue\": \"{\\\"ARoUEWW\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"sequence\\\":\\\"P6GYJKc3t0\\\",\\\"identifier\\\":\\\"P6GYJKc3t0\\\",\\\"data\\\":\\\"P6GYJKc3t0\\\",\\\"filterFactor\\\":\\\"P6GYJKc3t0\\\"}}}\",\n \"operateTime\": 1772700311648,\n \"type\": \"row\",\n \"version\": 2454\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{\\\"kFwb8br\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"uvZmbeb5RH\\\",\\\"sequence\\\":\\\"uvZmbeb5RH\\\",\\\"data\\\":\\\"uvZmbeb5RH\\\",\\\"filterFactor\\\":\\\"uvZmbeb5RH\\\"}}}\",\n \"oldValue\": \"{}\",\n \"operateTime\": 1772700308342,\n \"type\": \"row\",\n \"version\": 2453\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{}\",\n \"oldValue\": \"{\\\"kFwb8br\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"sequence\\\":\\\"1ZiuS5Wlgz\\\",\\\"identifier\\\":\\\"1ZiuS5Wlgz\\\",\\\"data\\\":\\\"1ZiuS5Wlgz\\\",\\\"filterFactor\\\":\\\"1ZiuS5Wlgz\\\"}}}\",\n \"operateTime\": 1772700305961,\n \"type\": \"row\",\n \"version\": 2452\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{}\",\n \"oldValue\": \"{\\\"ty1M3J4\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"sequence\\\":\\\"给不挠了\\\",\\\"identifier\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"给不挠了\\\\\\\"]]]]\\\",\\\"data\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"给不挠了\\\\\\\"]]]]\\\",\\\"filterFactor\\\":[\\\"给不挠了\\\"]}}}\",\n \"operateTime\": 1772700303480,\n \"type\": \"row\",\n \"version\": 2451\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{\\\"ty1M3J4\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"给不挠了\\\\\\\"]]]]\\\",\\\"sequence\\\":\\\"给不挠了\\\",\\\"data\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"给不挠了\\\\\\\"]]]]\\\",\\\"filterFactor\\\":[\\\"给不挠了\\\"]}}}\",\n \"oldValue\": \"{\\\"ty1M3J4\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"sequence\\\":\\\"闲置\\\",\\\"identifier\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"闲置\\\\\\\"]]]]\\\",\\\"data\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"闲置\\\\\\\"]]]]\\\",\\\"filterFactor\\\":[\\\"闲置\\\"]}}}\",\n \"operateTime\": 1772003207333,\n \"type\": \"row\",\n \"version\": 2377\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{\\\"ty1M3J4\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"闲置\\\\\\\"]]]]\\\",\\\"sequence\\\":\\\"闲置\\\",\\\"data\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"闲置\\\\\\\"]]]]\\\",\\\"filterFactor\\\":[\\\"闲置\\\"]}}}\",\n \"oldValue\": \"{\\\"ty1M3J4\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"目前查是离线\\\\\\\"]]]]\\\",\\\"sequence\\\":\\\"目前查是离线\\\",\\\"data\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"目前查是离线\\\\\\\"]]]]\\\",\\\"filterFactor\\\":[\\\"目前查是离线\\\"]}}}\",\n \"operateTime\": 1770990377587,\n \"type\": \"row\",\n \"version\": 2241\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{\\\"kFwb8br\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"1ZiuS5Wlgz\\\",\\\"sequence\\\":\\\"1ZiuS5Wlgz\\\",\\\"data\\\":\\\"1ZiuS5Wlgz\\\",\\\"filterFactor\\\":\\\"1ZiuS5Wlgz\\\"}}}\",\n \"oldValue\": \"{}\",\n \"operateTime\": 1770875502420,\n \"type\": \"row\",\n \"version\": 2162\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{}\",\n \"oldValue\": \"{\\\"kFwb8br\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"8G9fnUmdKY\\\",\\\"sequence\\\":\\\"8G9fnUmdKY\\\",\\\"data\\\":\\\"8G9fnUmdKY\\\",\\\"filterFactor\\\":\\\"8G9fnUmdKY\\\"}}}\",\n \"operateTime\": 1770875501255,\n \"type\": \"row\",\n \"version\": 2161\n },\n {\n \"action\": \"updateRecords\",\n \"newValue\": \"{\\\"ty1M3J4\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"identifier\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"目前查是离线\\\\\\\"]]]]\\\",\\\"sequence\\\":\\\"目前查是离线\\\",\\\"data\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"目前查是离线\\\\\\\"]]]]\\\",\\\"filterFactor\\\":[\\\"目前查是离线\\\"]}}}\",\n \"oldValue\": \"{\\\"ty1M3J4\\\":{\\\"dataType\\\":\\\"OBJECT\\\",\\\"value\\\":{\\\"sequence\\\":\\\"\\\",\\\"identifier\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"\\\\\\\"]]]]\\\",\\\"data\\\":\\\"[\\\\\\\"root\\\\\\\",{},[\\\\\\\"p\\\\\\\",{},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"text\\\\\\\"},[\\\\\\\"span\\\\\\\",{\\\\\\\"data-type\\\\\\\":\\\\\\\"leaf\\\\\\\"},\\\\\\\"\\\\\\\"]]]]\\\",\\\"filterFactor\\\":[\\\"\\\"]}}}\",\n \"operateTime\": 1770868083188,\n \"type\": \"row\",\n \"version\": 2139\n }\n ]\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Got 10 history record(s) for record 1015oH3OXy\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1156 - }, - { - "service": "aitable", - "command": "+record-query", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +record-query --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-ids 1015oH3OXy --field-ids 01ZM8y7 --filters '{}' --sort '[]' --query '测试' --limit 10 --cursor 0 --yes --format json", - "args": [ - "aitable", - "+record-query", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--record-ids", - "1015oH3OXy", - "--field-ids", - "01ZM8y7", - "--filters", - "{}", - "--sort", - "[]", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {},\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Successfully queried 0 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1241 - }, - { - "service": "aitable", - "command": "+record-query-empty", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +record-query-empty --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --limit 10 --cursor 0 --yes --format json", - "args": [ - "aitable", - "+record-query-empty", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"nextCursor\": \"4lLnbs69uY\",\n \"records\": []\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Scanned 10 record(s) from table hERWDMS in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk, 0 of them are empty.\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2057 - }, - { - "service": "aitable", - "command": "+record-share-links", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +record-share-links --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table hERWDMS --record-ids 1015oH3OXy --view-id qvGDAH2 --yes --format json", - "args": [ - "aitable", - "+record-share-links", - "--base", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table", - "hERWDMS", - "--record-ids", - "1015oH3OXy", - "--view-id", - "qvGDAH2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"base\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"batches\": 1,\n \"items\": [\n {\n \"recordId\": \"1015oH3OXy\",\n \"shareUrl\": \"https://alidocs.dingtalk.com/notable/record?dentryUuid=gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk&sheetId=hERWDMS&rowId=1015oH3OXy&viewId=qvGDAH2\"\n }\n ],\n \"table\": \"hERWDMS\",\n \"total\": 1\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1249 - }, - { - "service": "aitable", - "command": "+record-share-url", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +record-share-url --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-ids 1015oH3OXy --view-id qvGDAH2 --yes --format json", - "args": [ - "aitable", - "+record-share-url", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--record-ids", - "1015oH3OXy", - "--view-id", - "qvGDAH2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"items\": [\n {\n \"recordId\": \"1015oH3OXy\",\n \"shareUrl\": \"https://alidocs.dingtalk.com/notable/record?dentryUuid=gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\\u0026sheetId=hERWDMS\\u0026rowId=1015oH3OXy\\u0026viewId=qvGDAH2\"\n }\n ]\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Got share urls for 1 record(s)\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1130 - }, - { - "service": "aitable", - "command": "+resolve-base", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +resolve-base --name 'Real共创版设备去向登记' --yes --format json", - "args": [ - "aitable", - "+resolve-base", - "--name", - "Real共创版设备去向登记", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"name\": \"Real共创版设备去向登记\",\n \"resolved\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1228 - }, - { - "service": "aitable", - "command": "+resolve-table", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +resolve-table --base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --name 'Mac Mini' --yes --format json", - "args": [ - "aitable", - "+resolve-table", - "--base", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--name", - "Mac Mini", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"base\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"name\": \"Mac Mini\",\n \"resolved\": true,\n \"tableId\": \"hERWDMS\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1425 - }, - { - "service": "aitable", - "command": "+role-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +role-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json", - "args": [ - "aitable", - "+role-list", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"defaultRole\": {\n \"mode\": 2\n },\n \"enabled\": true,\n \"roles\": [\n {\n \"name\": \"所有者\",\n \"roleId\": \"owner\",\n \"roleType\": \"5000\",\n \"subRoles\": [],\n \"system\": true\n },\n {\n \"name\": \"管理员\",\n \"roleId\": \"manager\",\n \"roleType\": \"4000\",\n \"subRoles\": [],\n \"system\": true\n },\n {\n \"name\": \"可查看角色\",\n \"roleId\": \"10181777133\",\n \"roleType\": \"custom\",\n \"subRoles\": [\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 255\n },\n \"display\": {\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置\",\n \"targetTypeLabel\": \"仪表盘\"\n },\n \"targetId\": \"KY9tlWg5NEHgfs8WT6IO2\",\n \"targetType\": \"dashboard\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"hERWDMS\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"k8d898q\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"zyLRwwa\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"L53jNZz\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"zTiHNDL\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"anMdihs\",\n \"targetType\": \"sheet\"\n }\n ],\n \"system\": false\n },\n {\n \"name\": \"notable_biz_advanced_permission_role_editor\",\n \"roleId\": \"10181777134\",\n \"roleType\": \"system_editor\",\n \"subRoles\": [\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 255\n },\n \"display\": {\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置\",\n \"targetTypeLabel\": \"仪表盘\"\n },\n \"targetId\": \"KY9tlWg5NEHgfs8WT6IO2\",\n \"targetType\": \"dashboard\"\n },\n {\n \"authLevel\": \"manage\",\n \"display\": {\n \"authLevelLabel\": \"可管理\",\n \"permissionScopeNote\": \"可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断\",\n \"targetTypeLabel\": \"数据表\"\n },\n \"targetId\": \"hERWDMS\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"manage\",\n \"display\": {\n \"authLevelLabel\": \"可管理\",\n \"permissionScopeNote\": \"可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断\",\n \"targetTypeLabel\": \"数据表\"\n },\n \"targetId\": \"k8d898q\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"manage\",\n \"display\": {\n \"authLevelLabel\": \"可管理\",\n \"permissionScopeNote\": \"可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断\",\n \"targetTypeLabel\": \"数据表\"\n },\n \"targetId\": \"zyLRwwa\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"manage\",\n \"display\": {\n \"authLevelLabel\": \"可管理\",\n \"permissionScopeNote\": \"可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断\",\n \"targetTypeLabel\": \"数据表\"\n },\n \"targetId\": \"L53jNZz\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"manage\",\n \"display\": {\n \"authLevelLabel\": \"可管理\",\n \"permissionScopeNote\": \"可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断\",\n \"targetTypeLabel\": \"数据表\"\n },\n \"targetId\": \"zTiHNDL\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"manage\",\n \"display\": {\n \"authLevelLabel\": \"可管理\",\n \"permissionScopeNote\": \"可管理级别下,数据表拥有全部操作权限,actions/rows/cells 仅作为配置回显,不参与权限判断\",\n \"targetTypeLabel\": \"数据表\"\n },\n \"targetId\": \"anMdihs\",\n \"targetType\": \"sheet\"\n }\n ],\n \"system\": true\n },\n {\n \"name\": \"notable_biz_advanced_permission_role_reader\",\n \"roleId\": \"10181777135\",\n \"roleType\": \"system_reader\",\n \"subRoles\": [\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 255\n },\n \"display\": {\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,该目标可见但不可编辑,无细粒度 actions/rows/cells 配置\",\n \"targetTypeLabel\": \"仪表盘\"\n },\n \"targetId\": \"KY9tlWg5NEHgfs8WT6IO2\",\n \"targetType\": \"dashboard\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"hERWDMS\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"k8d898q\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"zyLRwwa\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"L53jNZz\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"zTiHNDL\",\n \"targetType\": \"sheet\"\n },\n {\n \"authLevel\": \"read\",\n \"config\": {\n \"actions\": 268435455\n },\n \"display\": {\n \"actionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ],\n \"actionsNote\": \"actions 位图取哨兵值 0x0FFFFFFF,在仅查看级别下表示视图配置项可见,不代表可对视图执行操作\",\n \"authLevelLabel\": \"仅查看\",\n \"permissionScopeNote\": \"仅查看级别下,actions/rows/cells 描述的是可见数据范围,不代表实际可执行的操作\",\n \"targetTypeLabel\": \"数据表\",\n \"viewActionsLabels\": [\n \"新增视图\",\n \"删除视图\",\n \"修改视图\"\n ]\n },\n \"targetId\": \"anMdihs\",\n \"targetType\": \"sheet\"\n }\n ],\n \"system\": true\n }\n ]\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Listed 5 roles for base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1483 - }, - { - "service": "aitable", - "command": "+section-list-empty", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +section-list-empty --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json", - "args": [ - "aitable", - "+section-list-empty", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"items\": [],\n \"total\": 0\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Found 0 empty sections in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1202 - }, - { - "service": "aitable", - "command": "+section-list-nodes", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +section-list-nodes --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --yes --format json", - "args": [ - "aitable", - "+section-list-nodes", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"items\": [\n {\n \"nodeId\": \"L53jNZz\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"zTiHNDL\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"TG566ix\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"hERWDMS-HaFV1LW\",\n \"nodeType\": \"form_view\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"KY9tlWg5NEHgfs8WT6IO2\",\n \"nodeType\": \"dashboard\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"TWB385A\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"hERWDMS-lmeV1cb\",\n \"nodeType\": \"form_view\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"anMdihs\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"qid57iq\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"k8d898q-mkGX58x\",\n \"nodeType\": \"form_view\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"k8d898q\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"zyLRwwa\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n },\n {\n \"nodeId\": \"hERWDMS\",\n \"nodeType\": \"sheet\",\n \"parentSectionId\": \"\"\n }\n ],\n \"total\": 13\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Found 13 nsheet nodes in base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 969 - }, - { - "service": "aitable", - "command": "+table-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +table-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-ids x --yes --format json", - "args": [ - "aitable", - "+table-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-ids", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"tables\": []\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Got 0 tables from base gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk. Requested tableIds not returned: x. URL rules: AI 表格数据表访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}; AI 表格视图访问地址: https://docs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}. User-facing guidance: 面向用户时要以人为本、友好直观,优先展示 **实体名称**、可点击链接和必要上下文,尽量隐藏 `baseId`、`tableId`、`viewId`、`dashboardId`、`documentId` 等技术细节;除非用户明确索要或排障必须,否则不要直接呈现这些 ID。\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1301 - }, - { - "service": "aitable", - "command": "+template-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +template-search --query '测试' --limit 10 --cursor 0 --yes --format json", - "args": [ - "aitable", - "+template-search", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 2,\n \"templates\": [\n {\n \"templateId\": \"513\",\n \"templateName\": \"随堂小测试\"\n },\n {\n \"templateId\": \"425\",\n \"templateName\": \"随堂小测试\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 953 - }, - { - "service": "aitable", - "command": "+view-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +view-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-ids qvGDAH2 --yes --format json", - "args": [ - "aitable", - "+view-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--view-ids", - "qvGDAH2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 1,\n \"views\": [\n {\n \"viewId\": \"qvGDAH2\",\n \"viewName\": \"Mac Mini\",\n \"viewType\": \"Grid\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1137 - }, - { - "service": "aitable", - "command": "+view-get-frozen-cols", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +view-get-frozen-cols --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id qvGDAH2 --yes --format json", - "args": [ - "aitable", - "+view-get-frozen-cols", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--view-id", - "qvGDAH2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"count\": 1,\n \"tableId\": \"hERWDMS\",\n \"viewId\": \"qvGDAH2\"\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"Frozen column count of view 'Mac Mini' is 1\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1097 - }, - { - "service": "aitable", - "command": "+view-get-lock", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +view-get-lock --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id qvGDAH2 --yes --format json", - "args": [ - "aitable", - "+view-get-lock", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--view-id", - "qvGDAH2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"locked\": false,\n \"tableId\": \"hERWDMS\",\n \"viewId\": \"qvGDAH2\"\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"View 'Mac Mini' is unlocked\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1063 - }, - { - "service": "aitable", - "command": "+view-get-row-height", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test aitable +view-get-row-height --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --view-id qvGDAH2 --yes --format json", - "args": [ - "aitable", - "+view-get-row-height", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--view-id", - "qvGDAH2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"data\": {\n \"baseId\": \"gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk\",\n \"tableId\": \"hERWDMS\",\n \"viewId\": \"qvGDAH2\"\n },\n \"error\": {},\n \"meta\": {},\n \"status\": \"success\",\n \"success\": true,\n \"summary\": \"View 'Mac Mini' has no explicit cell height\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1284 - }, - { - "service": "attendance", - "command": "+check-record", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +check-record --users 202397 --start 2026-07-14 --end 2026-07-15 --yes --format json", - "args": [ - "attendance", - "+check-record", - "--users", - "202397", - "--start", - "2026-07-14", - "--end", - "2026-07-15", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 998 - }, - { - "service": "attendance", - "command": "+check-result", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +check-result --users 202397 --start 2026-07-14 --end 2026-07-15 --offset 1 --limit 10 --yes --format json", - "args": [ - "attendance", - "+check-result", - "--users", - "202397", - "--start", - "2026-07-14", - "--end", - "2026-07-15", - "--offset", - "1", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1134 - }, - { - "service": "attendance", - "command": "+get-adjustment-rule", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-adjustment-rule --adjustment-id 1 --yes --format json", - "args": [ - "attendance", - "+get-adjustment-rule", - "--adjustment-id", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"success\": true,\n \"有效期类型\": \"工作日\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 950 - }, - { - "service": "attendance", - "command": "+get-approve-template", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-approve-template --type leave --yes --format json", - "args": [ - "attendance", - "+get-approve-template", - "--type", - "leave", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [\n {\n \"approveType\": \"LEAVE\",\n \"formName\": \"请假\",\n \"processCode\": \"PROC-940F2B1F-845D-4F39-A92E-FA5F45D1BF87\",\n \"submitUrl\": \"dingtalk://dingtalkclient/action/openapp?app_id=-4\\u0026container_type=work_platform\\u0026corpid=ding8196cd9a2b2405da24f2f5cc6abecb85\\u0026ddtab=true\\u0026redirect_type=jump\\u0026redirect_url=https%3A%2F%2Faflow.dingtalk.com%2Fdingtalk%2Fmobile%2Fhomepage.htm%3FappUuid%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26backcontrol%3Dfalse%26corpid%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26dd_progress%3Dfalse%26dd_share%3Dfalse%26ddtab%3Dtrue%26showmenu%3Dfalse%23%2Fcustom%3Fpcredirect%3Dself%26processCode%3DPROC-940F2B1F-845D-4F39-A92E-FA5F45D1BF87\"\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1080 - }, - { - "service": "attendance", - "command": "+get-checkin-record", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-checkin-record --operator-corp-id x --operator-staff-id 202397 --staff-ids 202397 --start '2026-07-15 10:00:00' --end '2026-07-15 16:00:00' --yes --format json", - "args": [ - "attendance", - "+get-checkin-record", - "--operator-corp-id", - "x", - "--operator-staff-id", - "202397", - "--staff-ids", - "202397", - "--start", - "2026-07-15 10:00:00", - "--end", - "2026-07-15 16:00:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"result\": {\n \"list\": []\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1116 - }, - { - "service": "attendance", - "command": "+get-leave-records", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-leave-records --user 202397 --leave-code 731ed089-62ff-4734-a6c7-3c8fcc8294fc --start 2026-07-14 --end 2026-07-15 --yes --format json", - "args": [ - "attendance", - "+get-leave-records", - "--user", - "202397", - "--leave-code", - "731ed089-62ff-4734-a6c7-3c8fcc8294fc", - "--start", - "2026-07-14", - "--end", - "2026-07-15", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1009 - }, - { - "service": "attendance", - "command": "+get-overtime-rule", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-overtime-rule --overtime-id 1 --yes --format json", - "args": [ - "attendance", - "+get-overtime-rule", - "--overtime-id", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [\n {\n \"advanceSettingDays\": [],\n \"content\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"durationSettings\": {\n \"holiday\": {\n \"calcType\": 1,\n \"durationType\": 1,\n \"holidayPlanOvertimeRedress\": null,\n \"holidayPlanOvertimeRedressBy\": null,\n \"holidayPlanVacationRate\": null,\n \"overtimeRedress\": true,\n \"overtimeRedressBy\": \"vacation\",\n \"roundType\": null,\n \"settings\": {},\n \"skipTime\": \"none\",\n \"skipTimeByDurations\": [],\n \"skipTimeByFrames\": [],\n \"stepType\": null,\n \"stepValue\": null,\n \"vacationRate\": \"1.0\",\n \"warningSettings\": [],\n \"workMinutesPerDay\": null\n },\n \"restDay\": {\n \"calcType\": 1,\n \"durationType\": 1,\n \"holidayPlanOvertimeRedress\": null,\n \"holidayPlanOvertimeRedressBy\": null,\n \"holidayPlanVacationRate\": null,\n \"overtimeRedress\": true,\n \"overtimeRedressBy\": \"vacation\",\n \"roundType\": null,\n \"settings\": {},\n \"skipTime\": \"none\",\n \"skipTimeByDurations\": [],\n \"skipTimeByFrames\": [],\n \"stepType\": null,\n \"stepValue\": null,\n \"vacationRate\": \"1.0\",\n \"warningSettings\": [],\n \"workMinutesPerDay\": null\n },\n \"workDay\": {\n \"calcType\": 1,\n \"durationType\": 1,\n \"holidayPlanOvertimeRedress\": null,\n \"holidayPlanOvertimeRedressBy\": null,\n \"holidayPlanVacationRate\": null,\n \"overtimeRedress\": true,\n \"overtimeRedressBy\": \"vacation\",\n \"roundType\": null,\n \"settings\": {\n \"calcTypeBetweenDutyWorktime\": null,\n \"durationTypeBetweenDutyWorktime\": null,\n \"dutyWorktimeLieuTypeVacationCode\": null,\n \"dutyWorktimeOvertimeRedress\": null,\n \"dutyWorktimeOvertimeRedressBy\": null,\n \"dutyWorktimeVacationRate\": null,\n \"enableAfterOffDuty\": true,\n \"enableBeforeOnDuty\": true,\n \"enableBetweenDuty\": true,\n \"enableBetweenDutyWorktime\": null,\n \"startTime\": null,\n \"timeUnit\": null,\n \"timeUnitAfterOffDuty\": 30,\n \"timeUnitBeforeOnDuty\": 30,\n \"timeUnitBetweenDuty\": 0,\n \"timeUnitBetweenDutyWorktime\": null\n },\n \"skipTime\": \"none\",\n \"skipTimeByDurations\": [],\n \"skipTimeByFrames\": [],\n \"stepType\": null,\n \"stepValue\": null,\n \"vacationRate\": \"1.0\",\n \"warningSettings\": [],\n \"workMinutesPerDay\": null\n }\n },\n \"groupIdAndNames\": [],\n \"id\": 941615052,\n \"isDefault\": null,\n \"name\": \"以审批时间计算加班\",\n \"overtimeCheckSetting\": {},\n \"overtimeDivisions\": [],\n \"owner\": null,\n \"ownerList\": [],\n \"roundType\": \"floor\",\n \"scopes\": null,\n \"settingId\": 941615052,\n \"stepType\": 0,\n \"stepValue\": \"1.0\",\n \"warningSettings\": [],\n \"workMinutesPerDay\": 480\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 961 - }, - { - "service": "attendance", - "command": "+get-schedule", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-schedule --users 202397 --start 2026-07-14 --end 2026-07-15 --yes --format json", - "args": [ - "attendance", - "+get-schedule", - "--users", - "202397", - "--start", - "2026-07-14", - "--end", - "2026-07-15", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 929 - }, - { - "service": "attendance", - "command": "+get-self-setting", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-self-setting --setting-scene checkRemind --user 202397 --yes --format json", - "args": [ - "attendance", - "+get-self-setting", - "--setting-scene", - "checkRemind", - "--user", - "202397", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": {\n \"bossMonthReportType\": null,\n \"bossPushStartMin\": null,\n \"bossWeekReportType\": null,\n \"canUpdateOffDuty\": null,\n \"checkRemindSetting\": {},\n \"checkRemindUserOffDuty\": true,\n \"checkRemindUserOnDuty\": true,\n \"checkResultMsg\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"enableOffdutyCheckRemindOfPc\": true,\n \"enableOndutyCheckRemindOfPc\": true,\n \"fastCheckLateNeedConfirm\": null,\n \"lackRemindUser\": null,\n \"lackSendTodoMsg\": null,\n \"offdutyCheckType\": null,\n \"offdutyRemindEndMin\": null,\n \"offdutyRemindStartMin\": null,\n \"ondutyCheckType\": null,\n \"ondutyRemindEndMin\": null,\n \"ondutyRemindStartMin\": null,\n \"personDailyReportSwitch\": null,\n \"personMonthReportType\": null,\n \"personWeekReportType\": null,\n \"userId\": \"202397\",\n \"vibrationRemindSwitch\": null,\n \"voiceRemindSwitch\": null\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 989 - }, - { - "service": "attendance", - "command": "+get-summary", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +get-summary --user 202397 --date 2026-07-15 --stats-type week --yes --format json", - "args": [ - "attendance", - "+get-summary", - "--user", - "202397", - "--date", - "2026-07-15", - "--stats-type", - "week", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"errorCode\": \"0\",\n \"errorMessage\": \"success\",\n \"result\": {\n \"items\": [\n {\n \"name\": \"平均工时\",\n \"remark\": \"0小时\",\n \"unit\": \"小时\"\n },\n {\n \"name\": \"出勤天数\",\n \"remark\": \"0天\",\n \"unit\": null\n },\n {\n \"name\": \"出勤班次\",\n \"remark\": \"0个\",\n \"unit\": null\n },\n {\n \"name\": \"休息天数\",\n \"remark\": \"0天\",\n \"unit\": null\n },\n {\n \"name\": \"迟到\",\n \"remark\": \"0次\",\n \"unit\": \"次\"\n },\n {\n \"name\": \"早退\",\n \"remark\": \"0次\",\n \"unit\": \"次\"\n },\n {\n \"name\": \"缺卡\",\n \"remark\": \"0次\",\n \"unit\": \"次\"\n },\n {\n \"name\": \"旷工\",\n \"remark\": \"0天\",\n \"unit\": null\n },\n {\n \"name\": \"外勤\",\n \"remark\": \"0次\",\n \"unit\": null\n },\n {\n \"name\": \"加班\",\n \"remark\": \"0小时\",\n \"unit\": null\n },\n {\n \"name\": \"补卡申请\",\n \"remark\": \"0次\",\n \"unit\": null\n }\n ]\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1315 - }, - { - "service": "attendance", - "command": "+list-approve", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +list-approve --users 202397 --types leave --start 2026-07-14 --end 2026-07-15 --yes --format json", - "args": [ - "attendance", - "+list-approve", - "--users", - "202397", - "--types", - "leave", - "--start", - "2026-07-14", - "--end", - "2026-07-15", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1208 - }, - { - "service": "attendance", - "command": "+list-leave-types", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +list-leave-types --yes --format json", - "args": [ - "attendance", - "+list-leave-types", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"result\": [\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"754f72b4-0476-4e7d-ae4c-c46486268c5c\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"事假\",\n \"leaveStatisticType\": \"freedom\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_hour\",\n \"leaveViewUnit\": \"hour\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"a49f333e-275b-489a-8ec7-2c2000b81f35\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"调休\",\n \"leaveStatisticType\": \"balance_cycle_freedom\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_hour\",\n \"leaveViewUnit\": \"hour\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"5c4380ea-6f9c-4955-bbe6-bc0d114e4676\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"病假\",\n \"leaveStatisticType\": \"freedom\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_hour\",\n \"leaveViewUnit\": \"hour\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"731ed089-62ff-4734-a6c7-3c8fcc8294fc\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"年假\",\n \"leaveStatisticType\": \"balance_cycle\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_day\",\n \"leaveViewUnit\": \"halfDay\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"8a551c65-d3e1-4671-9eaf-78dd4645b8c3\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"产假\",\n \"leaveStatisticType\": \"once_max\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_day\",\n \"leaveViewUnit\": \"day\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"904d3f51-1f73-4161-a50a-300d0009676d\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"陪产假\",\n \"leaveStatisticType\": \"once_max\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_day\",\n \"leaveViewUnit\": \"day\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"05e2ba99-1916-4be9-8166-54f6a652e3bc\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"婚假\",\n \"leaveStatisticType\": \"once_max\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_day\",\n \"leaveViewUnit\": \"day\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"b168cb41-fbca-4f28-8234-fc62e0b66a2b\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"例假\",\n \"leaveStatisticType\": \"once_max\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_day\",\n \"leaveViewUnit\": \"halfDay\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"9daf653b-b3dd-46ef-a2b0-dd160bbf08c4\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"丧假\",\n \"leaveStatisticType\": \"once_max\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_day\",\n \"leaveViewUnit\": \"day\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n },\n {\n \"allRecordCleanTime\": null,\n \"bizType\": null,\n \"corpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\",\n \"exsitFlag\": \"y\",\n \"gmtCreate\": 1650879712000,\n \"gmtModified\": 1650879712000,\n \"leaveBalanceRuleVO\": {},\n \"leaveCertificate\": {},\n \"leaveCode\": \"3730df36-c05c-45e7-bac1-26b2ba137a25\",\n \"leaveCycleStatisticInfoVO\": {},\n \"leaveHourCeil\": null,\n \"leaveName\": \"哺乳假\",\n \"leaveStatisticType\": \"freedom\",\n \"leaveStatus\": \"on\",\n \"leaveTimeCeil\": null,\n \"leaveTimeCeilMinUnit\": null,\n \"leaveUnit\": \"percent_hour\",\n \"leaveViewUnit\": \"hour\",\n \"maxLeaveTime\": null,\n \"minLeaveHour\": null,\n \"naturalDayLeave\": false,\n \"orderId\": null,\n \"paidLeave\": false,\n \"perHoursInDay\": 0,\n \"remindRuleVO\": {},\n \"source\": \"inner\",\n \"submitTimeRuleVO\": {},\n \"sysFlag\": \"n\",\n \"userId\": null,\n \"version\": \"1\",\n \"visibilityRules\": [],\n \"whenCanLeave\": \"entry\"\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1107 - }, - { - "service": "attendance", - "command": "+my-attendance", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +my-attendance --yes --format json", - "args": [ - "attendance", - "+my-attendance", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1169 - }, - { - "service": "attendance", - "command": "+query-report-data", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +query-report-data --users 202397 --columns 1001 --start '2026-07-15 10:00:00' --end '2026-07-15 16:00:00' --yes --format json", - "args": [ - "attendance", - "+query-report-data", - "--users", - "202397", - "--columns", - "1001", - "--start", - "2026-07-15 10:00:00", - "--end", - "2026-07-15 16:00:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 998 - }, - { - "service": "attendance", - "command": "+search-adjustment-rule", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +search-adjustment-rule --query '测试' --page 1 --limit 10 --yes --format json", - "args": [ - "attendance", - "+search-adjustment-rule", - "--query", - "测试", - "--page", - "1", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"rules\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 985 - }, - { - "service": "attendance", - "command": "+search-class", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +search-class --query '测试' --filter-type ALL --page 1 --limit 10 --yes --format json", - "args": [ - "attendance", - "+search-class", - "--query", - "测试", - "--filter-type", - "ALL", - "--page", - "1", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"classes\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1043 - }, - { - "service": "attendance", - "command": "+search-group", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +search-group --query '测试' --type FIXED --query-position --query-ble --page 1 --limit 10 --yes --format json", - "args": [ - "attendance", - "+search-group", - "--query", - "测试", - "--type", - "FIXED", - "--query-position", - "--query-ble", - "--page", - "1", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"groups\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 939 - }, - { - "service": "attendance", - "command": "+search-overtime-rule", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +search-overtime-rule --query '测试' --page 1 --limit 10 --yes --format json", - "args": [ - "attendance", - "+search-overtime-rule", - "--query", - "测试", - "--page", - "1", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"rules\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 997 - }, - { - "service": "attendance", - "command": "+this-month", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test attendance +this-month --yes --format json", - "args": [ - "attendance", - "+this-month", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"code\": \"0\",\n \"message\": \"success\",\n \"result\": [],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1108 - }, - { - "service": "calendar", - "command": "+agenda", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +agenda --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --calendar-id primary --cursor '' --limit 10 --yes --format json", - "args": [ - "calendar", - "+agenda", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--calendar-id", - "primary", - "--cursor", - "", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 1,\n \"events\": [\n {\n \"end\": {\n \"date\": null,\n \"dateTime\": \"2026-07-16T10:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\"\n },\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000\",\n \"location\": \"B310\",\n \"start\": {\n \"date\": null,\n \"dateTime\": \"2026-07-16T09:15:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\"\n },\n \"status\": \"confirmed\",\n \"summary\": \"中控专属钉&AI项目早会\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2094 - }, - { - "service": "calendar", - "command": "+attendee-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +attendee-list --event THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000 --calendar-id primary --yes --format json", - "args": [ - "calendar", - "+attendee-list", - "--event", - "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000", - "--calendar-id", - "primary", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"attendees\": [\n {\n \"displayName\": \"秦天\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"崇幻(主用钉)\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"恩澈主用钉\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"楠者\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"栩铭-主账号\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"思敏\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"念晨\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"杨淑平\",\n \"responseStatus\": \"declined\"\n },\n {\n \"displayName\": \"李伟韬\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"王琳\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"千启\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"粤腾\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"石涤\",\n \"responseStatus\": \"declined\"\n },\n {\n \"displayName\": \"陈叙\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"李畅(主用钉)\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"徐海儿\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"袁航\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王伟明\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"巫利华\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"张彪\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"舒畅\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王晓璇\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"孙茂林\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"尤福海\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"杨凯\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"杨二磊\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"姚俊杰\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"王鑫杰\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"毛金权\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王国红\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"刘刚\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王福超\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"张海蛟\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"方胜新\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"邵嘉铭\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"孙龙飞\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"刘琦\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"申展\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"李光辉\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"蒋志恒\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"籍敏\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"黄徐贤\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王继光\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"裴美娟\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"闫铎功\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"陈江义\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"刘颖\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"杨旭[工作号]\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"刘少军\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王贵\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"付玉强\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"张振奎\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"武永江\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"沈体峰\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"孔维娜\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王东亮\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"李鑫\",\n \"responseStatus\": \"accepted\"\n },\n {\n \"displayName\": \"张磊\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"朱晓闪\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"吴斌\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"陈露鑫\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"陈永纯\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"李所清\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"盛超\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"王军超\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"曲宁涛\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"方美\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"杨帆\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"万以华\",\n \"responseStatus\": \"needsAction\"\n },\n {\n \"displayName\": \"毕晨\",\n \"responseStatus\": \"accepted\"\n }\n ],\n \"count\": 70\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1144 - }, - { - "service": "calendar", - "command": "+book-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +book-list --yes --format json", - "args": [ - "calendar", - "+book-list", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"calendars\": [\n {\n \"calendarId\": \"primary\",\n \"description\": null,\n \"privilege\": \"owner\",\n \"summary\": \"我的日历(钉钉)\",\n \"type\": \"primary\"\n }\n ],\n \"count\": 1\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1033 - }, - { - "service": "calendar", - "command": "+book-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +book-search --query '测试' --yes --format json", - "args": [ - "calendar", - "+book-search", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"calendars\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1036 - }, - { - "service": "calendar", - "command": "+conflicts", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +conflicts --in-days 1 --yes --format json", - "args": [ - "calendar", - "+conflicts", - "--in-days", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"conflictCount\": 0,\n \"conflicts\": [],\n \"date\": \"2026-07-16\",\n \"eventCount\": 3,\n \"hasConflict\": false\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2369 - }, - { - "service": "calendar", - "command": "+free", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +free --who '冬翔' --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json", - "args": [ - "calendar", - "+free", - "--who", - "冬翔", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"busy\": [],\n \"free\": true,\n \"userId\": \"103262\",\n \"who\": \"夏东翔\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1697 - }, - { - "service": "calendar", - "command": "+free-slots", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +free-slots --in-days 1 --from 9 --to 18 --yes --format json", - "args": [ - "calendar", - "+free-slots", - "--in-days", - "1", - "--from", - "9", - "--to", - "18", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"allBusy\": false,\n \"date\": \"2026-07-16\",\n \"freeSlots\": [\n {\n \"durationMin\": 15,\n \"end\": \"09:15\",\n \"start\": \"09:00\"\n },\n {\n \"durationMin\": 480,\n \"end\": \"18:00\",\n \"start\": \"10:00\"\n }\n ],\n \"slotCount\": 2,\n \"window\": \"09:00-18:00\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2195 - }, - { - "service": "calendar", - "command": "+freebusy", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +freebusy --users 103262 --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json", - "args": [ - "calendar", - "+freebusy", - "--users", - "103262", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"result\": [\n {\n \"error\": null,\n \"roomId\": null,\n \"scheduleItems\": [],\n \"userId\": \"103262\"\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1154 - }, - { - "service": "calendar", - "command": "+my-free", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +my-free --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json", - "args": [ - "calendar", - "+my-free", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"busy\": [\n {\n \"end\": \"2026-07-16T10:00:00+08:00\",\n \"start\": \"2026-07-16T09:15:00+08:00\"\n }\n ],\n \"free\": false,\n \"userId\": \"202397\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1406 - }, - { - "service": "calendar", - "command": "+next-event", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +next-event --yes --format json", - "args": [ - "calendar", - "+next-event", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"event\": {\n \"end\": \"2026-07-15 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000\",\n \"start\": \"2026-07-15 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n }\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 5504 - }, - { - "service": "calendar", - "command": "+room-groups", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +room-groups --limit 10 --page 1 --yes --format json", - "args": [ - "calendar", - "+room-groups", - "--limit", - "10", - "--page", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"groups\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1224 - }, - { - "service": "calendar", - "command": "+room-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +room-search --room-name x --yes --format json", - "args": [ - "calendar", - "+room-search", - "--room-name", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"rooms\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1298 - }, - { - "service": "calendar", - "command": "+suggest-time", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +suggest-time --with '冬翔' --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --duration x --yes --format json", - "args": [ - "calendar", - "+suggest-time", - "--with", - "冬翔", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--duration", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"suggestions\": [\n {\n \"end\": \"2026-07-16T10:30:00+08:00\",\n \"start\": \"2026-07-16T10:00:00+08:00\"\n },\n {\n \"end\": \"2026-07-16T11:00:00+08:00\",\n \"start\": \"2026-07-16T10:30:00+08:00\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1711 - }, - { - "service": "calendar", - "command": "+today", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +today --yes --format json", - "args": [ - "calendar", - "+today", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"events\": [\n {\n \"end\": \"2026-07-15 10:00\",\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-15 09:15\",\n \"title\": \"中控专属钉&AI项目早会\"\n },\n {\n \"end\": \"2026-07-15 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000\",\n \"start\": \"2026-07-15 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1951 - }, - { - "service": "calendar", - "command": "+tomorrow", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +tomorrow --yes --format json", - "args": [ - "calendar", - "+tomorrow", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"events\": [\n {\n \"end\": \"2026-07-16 10:00\",\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-16 09:15\",\n \"title\": \"中控专属钉&AI项目早会\"\n },\n {\n \"end\": \"2026-07-16 20:00\",\n \"eventId\": \"cVRhbUZsMzZscU1XQjd0NURFaEdnZz09\",\n \"location\": \"张功馆·桐庐菜(未来星宸永福店)\",\n \"start\": \"2026-07-16 18:00\",\n \"title\": \"搜问&搜索团队聚餐\"\n },\n {\n \"end\": \"2026-07-16 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784206800000\",\n \"start\": \"2026-07-16 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2254 - }, - { - "service": "calendar", - "command": "+week", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +week --yes --format json", - "args": [ - "calendar", - "+week", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"events\": [\n {\n \"end\": \"2026-07-13 10:00\",\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1783905300000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-13 09:15\",\n \"title\": \"中控专属钉&AI项目早会\"\n },\n {\n \"end\": \"2026-07-13 15:30\",\n \"eventId\": \"UTFidStpV3JUcnVQRDhHZThjZVJGQT09\",\n \"start\": \"2026-07-13 15:00\",\n \"title\": \"llm-wiki的能力共创快速对一下使用说明\"\n },\n {\n \"end\": \"2026-07-13 16:15\",\n \"eventId\": \"bjZWVmlieHVpUUJJMm15RGFUMlRIZz09\",\n \"start\": \"2026-07-13 15:30\",\n \"title\": \"聊聊\"\n },\n {\n \"end\": \"2026-07-13 16:30\",\n \"eventId\": \"elVnVE45K1YvcStEbEpvM2RxbGVYQT09\",\n \"location\": \"2003\",\n \"start\": \"2026-07-13 16:00\",\n \"title\": \"AI技术平台小周会\"\n },\n {\n \"end\": \"2026-07-13 18:00\",\n \"eventId\": \"RXJDc2RMNzNPWkRZRnpzTU1NZ1Nsdz09_1783933200000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-13 17:00\",\n \"title\": \"AI营销周会\"\n },\n {\n \"end\": \"2026-07-13 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1783947600000\",\n \"start\": \"2026-07-13 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n },\n {\n \"end\": \"2026-07-14 10:00\",\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1783991700000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-14 09:15\",\n \"title\": \"中控专属钉&AI项目早会\"\n },\n {\n \"end\": \"2026-07-14 14:30\",\n \"eventId\": \"d1BpNmZOVkdJaFY5dXdIVE9SdkZPQT09\",\n \"location\": \"D1-2001\",\n \"start\": \"2026-07-14 13:30\",\n \"title\": \"简单聊一聊\"\n },\n {\n \"end\": \"2026-07-14 20:30\",\n \"eventId\": \"ZUtBTlQxNjY3TnJKM0JpVEx2T1Ardz09\",\n \"location\": \"D1-2004\",\n \"start\": \"2026-07-14 19:30\",\n \"title\": \"盘一下算法 x 搜索搜问合作\"\n },\n {\n \"end\": \"2026-07-14 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784034000000\",\n \"start\": \"2026-07-14 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n },\n {\n \"end\": \"2026-07-15 10:00\",\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-15 09:15\",\n \"title\": \"中控专属钉&AI项目早会\"\n },\n {\n \"end\": \"2026-07-15 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784120400000\",\n \"start\": \"2026-07-15 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n },\n {\n \"end\": \"2026-07-16 10:00\",\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784164500000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-16 09:15\",\n \"title\": \"中控专属钉&AI项目早会\"\n },\n {\n \"end\": \"2026-07-16 20:00\",\n \"eventId\": \"cVRhbUZsMzZscU1XQjd0NURFaEdnZz09\",\n \"location\": \"张功馆·桐庐菜(未来星宸永福店)\",\n \"start\": \"2026-07-16 18:00\",\n \"title\": \"搜问&搜索团队聚餐\"\n },\n {\n \"end\": \"2026-07-16 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784206800000\",\n \"start\": \"2026-07-16 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n },\n {\n \"end\": \"2026-07-17 10:00\",\n \"eventId\": \"THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784250900000\",\n \"location\": \"B310\",\n \"start\": \"2026-07-17 09:15\",\n \"title\": \"中控专属钉&AI项目早会\"\n },\n {\n \"end\": \"2026-07-17 18:00\",\n \"eventId\": \"bmVEbnArV25ITStSQlE5WU1QOHhJUT09_1784278800000\",\n \"location\": \"项目室(D1-1604)\",\n \"start\": \"2026-07-17 17:00\",\n \"title\": \"模型算法团队周会\"\n },\n {\n \"end\": \"2026-07-17 21:15\",\n \"eventId\": \"cy81YjB1eW1yMW9SeWRVdHNDVWFhZz09_1784293200000\",\n \"start\": \"2026-07-17 21:00\",\n \"title\": \"循环提醒:项目每日工作进展同步\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 6589 - }, - { - "service": "chat", - "command": "+at-me", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +at-me --days 7 --yes --format json", - "args": [ - "chat", - "+at-me", - "--days", - "7", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"messages\": [\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"粤腾\",\n \"text\": \"@梓寻 @崇幻(主用钉) @陈叙 @念晨 明天下午给孙总的蓝图汇报,最后一part是对本次AI营销项目的建议和求助。大家可以把每个模块目前遇到的问题和困难(例如:需要客户提供的资源、需求落地的阻力等),单独反馈给我。\",\n \"time\": \"2026-07-15 14:29:53\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@念晨 这个知识蓝图好了么?客户想先看一下材料\",\n \"time\": \"2026-07-15 13:41:25\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@念晨 @杨淑平 \\n1、**生产环境:补充一下是否需要域名**[图片消息](mediaId=@lQLPJxN0PcmAzMfNBfDNCuKwMJ8nHWzNI30KKoLRQapEAQ)**2、测试环境:**\\n(1)需要几台服务器,配置保持跟生产一样,还是改一下\\n(2)测试环境是否需要域名,(测试环境就写是否需要好了,可以让客户随机分配下)[图片消息](mediaId=@lQLPJwPJXqYLLMfNBf7NCqCw5iUu-ba7L7IKKoLRQapEAA)\",\n \"time\": \"2026-07-15 10:19:17\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"栩铭-主账号\",\n \"text\": \"@徐洪鑫(崇幻(主用钉)) @董鑫阳(念晨) @陈绪(陈叙) @杨淑平 @梓寻 \\n需要申请 测试服务器环境的同学,在https://alidocs.dingtalk.com/i/nodes/PwkYGxZV3ZEBnPvwI92oDm9rWAgozOKL?utm_scene=team_space&iframeQuery=sheet_range%3Dkgqie6hm_0_11_9_3 “测试环境”Sheet页登记下\\n需要域名的,填写在新增的“域名”栏目\",\n \"time\": \"2026-07-15 09:50:16\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"粤腾\",\n \"text\": \"@梓寻 @陈叙 @崇幻(主用钉) @念晨 各业务场景的蓝图素材还请大家统一补充到钉钉文档中 https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPzYvRArmHv23ye4kWzN67Mw4\",\n \"time\": \"2026-07-14 22:44:59\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@崇幻(主用钉) @陈叙 @念晨 @杨淑平 今日进展和明日计划记得发一下哈,\\n\\n另外再看一下情报助手、活动助手、解决方案助手、专家Agent什么时候可以让中控一线人员在自己的电脑上运行(最小MVP版本),要给下时间\",\n \"time\": \"2026-07-14 21:35:38\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@念晨 选李龙就是@粤腾 批啦[憨笑]\",\n \"time\": \"2026-07-14 20:33:28\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"粤腾\",\n \"text\": \"@崇幻(主用钉) @念晨 @梓寻 @陈叙 如果大家明天上午方便,最好能来中控现场,蓝图规划和场景demo录制两个事现场对齐高效输出。[抱拳]\",\n \"time\": \"2026-07-14 19:13:20\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"粤腾\",\n \"text\": \"@崇幻(主用钉) @陈叙 @梓寻 @念晨 明天要给新的分管It和营销业务副总裁孙总汇报整体项目蓝图规划,辛苦大家今天内把每个模块用户故事动线(产品原型图,demo界面,场景等)串起来的相关素材放到知识库。https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYrlp4LN6IBEylmR48akx1Z5N?corpId=ding5bb1eba46e6a17d94ac5d6980864d335&utm_medium=im_card&iframeQuery=utm_medium%3Dim_card%26utm_source%3Dim&utm_scene=team_space&utm_source=im\",\n \"time\": \"2026-07-14 16:28:06\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@念晨 @陈叙 会议室在 E307\",\n \"time\": \"2026-07-14 13:12:04\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"崇幻这边可以现场演示,@念晨 @陈叙 你俩下午赶的过来么?\",\n \"time\": \"2026-07-14 11:13:17\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@念晨 @粤腾 @梓寻 @陈叙 @崇幻(主用钉) 崔总突然下午要看项目整体进度,需要汇报每个子项目的情况,大概2点半这样。\",\n \"time\": \"2026-07-14 11:11:28\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@念晨 明日计划给你加个知识蓝图输出中,然后约客户周三对焦哈\",\n \"time\": \"2026-07-13 23:14:03\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"千启\",\n \"text\": \"@陈叙 @念晨 @杨淑平 今日进展和明天计划记得同步下[送花花]\",\n \"time\": \"2026-07-13 21:40:08\"\n },\n {\n \"conversation\": \"【内】中控每日工作进展汇总群\",\n \"sender\": \"粤腾\",\n \"text\": \"还差一个情报助理和 知识库与中控制标Agent打通。 @杨淑平 情报助理看下周三能做到什么程度。 @念晨 知识库与中控制标Agent打通 需要在7 18 的demo演示中打通么。\",\n \"time\": \"2026-07-13 16:43:23\"\n },\n {\n \"conversation\": \"算法团队mentor群(内部讨论群)\",\n \"sender\": \"璟琦(主用钉)\",\n \"text\": \"@董鑫阳(念晨) https://acl.alibaba-inc.com/permission/datapermission/applyDetail.htm?spm=21540d8c.6248353e.0.0.6ae2264c9zDMFB&flowId=3cd438c0-2cee-4eac-a763-4625dbf2b51d\",\n \"time\": \"2026-07-15 10:54:23\"\n },\n {\n \"conversation\": \"算法团队mentor群(内部讨论群)\",\n \"sender\": \"九韶\",\n \"text\": \"@董鑫阳(念晨) 给你留了一个目标, 你抓紧更新,进去。 我给大哥先发一般\",\n \"time\": \"2026-07-14 16:15:16\"\n },\n {\n \"conversation\": \"算法团队mentor群(内部讨论群)\",\n \"sender\": \"璟琦(主用钉)\",\n \"text\": \"@董鑫阳(念晨) 你补一下 中控 及 专家agent 的部分\",\n \"time\": \"2026-07-14 15:48:29\"\n },\n {\n \"conversation\": \"算法团队mentor群(内部讨论群)\",\n \"sender\": \"璟琦(主用钉)\",\n \"text\": \"@董鑫阳(念晨) cli实现的时候这里面的能力最好都支持\",\n \"time\": \"2026-07-14 10:49:39\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"筱淘\",\n \"text\": \"@董鑫阳(念晨) 让客户再等等 明天见客户\",\n \"time\": \"2026-07-15 09:48:49\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 没事,下次星爷的美人鱼2要在2028年上映了,到时候我们再一起翘班去\",\n \"time\": \"2026-07-15 09:41:06\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@卢翮(龙衔) @董鑫阳(念晨) 去么去么\",\n \"time\": \"2026-07-15 09:22:09\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) @蒋志刚(穆伦) @卢翮(龙衔) 去么,去么\",\n \"time\": \"2026-07-15 09:16:18\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 哪个 HR 问你\",\n \"time\": \"2026-07-15 09:10:25\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"穆伦\",\n \"text\": \"@Dennis(念晨) 为啥这么问呢?你们那边没有好的位置?\",\n \"time\": \"2026-07-15 09:05:14\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"筱淘\",\n \"text\": \"@董鑫阳(念晨) 还等什么 来吧\",\n \"time\": \"2026-07-15 09:00:21\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 害怕\",\n \"time\": \"2026-07-14 22:01:18\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"穆伦\",\n \"text\": \"@Dennis(念晨) 展开说说\",\n \"time\": \"2026-07-14 16:39:26\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"龙衔\",\n \"text\": \"@董鑫阳(念晨) 聊啥\",\n \"time\": \"2026-07-14 16:36:03\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"穆伦\",\n \"text\": \"@Dennis(念晨) 什么评价\",\n \"time\": \"2026-07-14 16:33:04\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 我就说旁边有个车神经的滴滴我\",\n \"time\": \"2026-07-14 13:04:30\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 赛银\",\n \"time\": \"2026-07-14 12:51:33\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 刚才出来的车是你的呀\",\n \"time\": \"2026-07-14 12:51:27\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 你最后一条为什么只出现一半\",\n \"time\": \"2026-07-14 10:27:07\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) iPad or Mac?\",\n \"time\": \"2026-07-14 10:23:28\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"龙衔\",\n \"text\": \"@董鑫阳(念晨) 走,请你吃面\",\n \"time\": \"2026-07-13 18:26:51\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"龙衔\",\n \"text\": \"@董鑫阳(念晨) 茶叶忘了给你了 下次吃饭吧 头大 脑子不够了\",\n \"time\": \"2026-07-13 14:14:22\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 下午你来\",\n \"time\": \"2026-07-13 11:37:02\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 你是开个新卡,还是试试筱淘那个100多的\",\n \"time\": \"2026-07-13 11:33:47\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨)\",\n \"time\": \"2026-07-13 11:32:43\"\n },\n {\n \"conversation\": \"AI全栈\",\n \"sender\": \"怒龙\",\n \"text\": \"@董鑫阳(念晨) 我给你开个卡?\",\n \"time\": \"2026-07-13 11:28:36\"\n },\n {\n \"conversation\": \"元智,九韶,璟琦(主用钉),念晨\",\n \"sender\": \"元智\",\n \"text\": \"@董鑫阳(念晨) 给力,scope更大了\",\n \"time\": \"2026-07-14 15:39:19\"\n },\n {\n \"conversation\": \"元智,九韶,璟琦(主用钉),念晨\",\n \"sender\": \"九韶\",\n \"text\": \"@董鑫阳(念晨) 汇报过了吗? 怎么样?\",\n \"time\": \"2026-07-14 15:19:43\"\n },\n {\n \"conversation\": \"[会议群]销售知识库方案讨论\",\n \"sender\": \"赵瑞(玖霄)\",\n \"text\": \"我来约你时间@念晨\",\n \"time\": \"2026-07-14 12:37:23\"\n },\n {\n \"conversation\": \"[会议群]销售知识库方案讨论\",\n \"sender\": \"迦卢\",\n \"text\": \"@赵瑞(玖霄) 你把需求和@念晨 对焦下\",\n \"time\": \"2026-07-14 12:25:15\"\n },\n {\n \"conversation\": \"[会议群]销售知识库方案讨论\",\n \"sender\": \"迦卢\",\n \"text\": \"@赵瑞(玖霄) @念晨 是知识库这边的技术架构师,目前还没有产品,但是他来进行和我们共创\",\n \"time\": \"2026-07-13 17:48:48\"\n },\n {\n \"conversation\": \"FDE Go\",\n \"sender\": \"冬翔\",\n \"text\": \"@董鑫阳(念晨) 你的Claude还是不能用?\",\n \"time\": \"2026-07-14 10:22:12\"\n },\n {\n \"conversation\": \"FDE Go\",\n \"sender\": \"黎常\",\n \"text\": \"@董鑫阳(念晨) 这个确实太傻逼了,放着好好优势不用\",\n \"time\": \"2026-07-14 09:55:49\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2787 - }, - { - "service": "chat", - "command": "+bot-find", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +bot-find --query '测试' --limit 10 --cursor '' --yes --format json", - "args": [ - "chat", - "+bot-find", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"bots\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1312 - }, - { - "service": "chat", - "command": "+bot-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +bot-search --page 1 --size 1 --name 'DWS shortcut 真实测试' --yes --format json", - "args": [ - "chat", - "+bot-search", - "--page", - "1", - "--size", - "1", - "--name", - "DWS shortcut 真实测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"robots\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1191 - }, - { - "service": "chat", - "command": "+category-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +category-list --yes --format json", - "args": [ - "chat", - "+category-list", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"categories\": [\n {\n \"categoryId\": 1561691365563,\n \"title\": \"大型摸鱼现场\"\n },\n {\n \"categoryId\": 1761880390389,\n \"title\": \"悟空\"\n },\n {\n \"categoryId\": 1561692216352,\n \"title\": \"钉钉\"\n },\n {\n \"categoryId\": 1761880350586,\n \"title\": \"RELU\"\n },\n {\n \"categoryId\": 1767577825401,\n \"title\": \"X Project\"\n },\n {\n \"categoryId\": 1770100726133,\n \"title\": \"A1合作\"\n },\n {\n \"categoryId\": 1764209474291,\n \"title\": \"CodeAgent\"\n },\n {\n \"categoryId\": 1764818531490,\n \"title\": \"DeepResearch\"\n },\n {\n \"categoryId\": 1765771015868,\n \"title\": \"J Project\"\n },\n {\n \"categoryId\": 1761881089886,\n \"title\": \"客户服务\"\n }\n ],\n \"count\": 10\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1326 - }, - { - "service": "chat", - "command": "+chat-bots", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-bots --group cid3Jijzhe2aqs9ysOXjhi05g== --yes --format json", - "args": [ - "chat", - "+chat-bots", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"bots\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 907 - }, - { - "service": "chat", - "command": "+chat-invite-url", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-invite-url --group cid3Jijzhe2aqs9ysOXjhi05g== --expires-seconds 1 --yes --format json", - "args": [ - "chat", - "+chat-invite-url", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--expires-seconds", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"inviteUrl\": \"https://qr.dingtalk.com/action/joingroup?code=v1,k1,Rj9aR+97xuRdK0cKsvoU/daj0MZj7p02n7pETjkgcMo=\\u0026_dt_no_comment=1\\u0026origin=11\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 958 - }, - { - "service": "chat", - "command": "+chat-list-all", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-list-all --limit 10 --cursor 0 --yes --format json", - "args": [ - "chat", - "+chat-list-all", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 9,\n \"groups\": [\n {\n \"name\": \"浅曦-kida,Dennis,秋画\",\n \"openConversationId\": \"cid3Jijzhe2aqs9ysOXjhi05g==\"\n },\n {\n \"name\": \"Dennis,梨岚,浅曦,秋画\",\n \"openConversationId\": \"cidBRb3r823j/rs8mGdPervyQ==\"\n },\n {\n \"name\": \"HISTORE_DINGDING_VOIP_APP\",\n \"openConversationId\": \"cid68WRQRLAWFUvSWPjFTxQEg==\"\n },\n {\n \"name\": \"钉钉19届应届生校招群\",\n \"openConversationId\": \"cidsXjWvcYMguAWhhE6x1PsRw==\"\n },\n {\n \"name\": \"钉钉尖角班第21期欢迎你!\",\n \"openConversationId\": \"cid2YTSHeguqsO/PjSu33/IRA==\"\n },\n {\n \"name\": \"钉子户\",\n \"openConversationId\": \"cidCVNiNIaY1vsA6Hp0rUvk7w==\"\n },\n {\n \"name\": \"应用通知\",\n \"openConversationId\": \"cidPrcdDg1wZb3/0oQRIWxVRw==\"\n },\n {\n \"name\": \"三口之家\",\n \"openConversationId\": \"cid8eLJsKOY3EwulLUqD3jVbA==\"\n },\n {\n \"name\": \"工作通知:三口之家\",\n \"openConversationId\": \"cidAHnUfD5n2F+Ybdsqeu9ymQ==\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1043 - }, - { - "service": "chat", - "command": "+chat-list-join-requests", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-list-join-requests --limit 10 --cursor 0 --yes --format json", - "args": [ - "chat", - "+chat-list-join-requests", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"hasMore\": false,\n \"nextCursor\": 0,\n \"records\": []\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 960 - }, - { - "service": "chat", - "command": "+chat-list-mine", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-list-mine --role OWNER --limit 10 --exclude-muted --yes --format json", - "args": [ - "chat", - "+chat-list-mine", - "--role", - "OWNER", - "--limit", - "10", - "--exclude-muted", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 10,\n \"groups\": [\n {\n \"name\": \"DING_VIBE_CODING没统计到token的问题\",\n \"openConversationId\": \"cidl/foiVj/HRZjBxQI4+Y81g==\"\n },\n {\n \"name\": \"钉钉 EveryDay X RELU 共创\",\n \"openConversationId\": \"cid7XlLm5toebOyqw0J3GJfmA==\"\n },\n {\n \"name\": \"中控测试\",\n \"openConversationId\": \"cidwnTdZmw2YMznZKLOmws9VQ==\"\n },\n {\n \"name\": \"国粹候选人\",\n \"openConversationId\": \"cidw+0WK0/N8nrKwvcgpg6AJQ==\"\n },\n {\n \"name\": \"X 项目算法支持\",\n \"openConversationId\": \"cidbDGRPLyhdI1/b85MiUSz5Q==\"\n },\n {\n \"name\": \"悟空 Agent Runtime Scrum\",\n \"openConversationId\": \"cidCAHeQDMT93RTArxiqBJLiA==\"\n },\n {\n \"name\": \"电商刊登接入CodeAgent\",\n \"openConversationId\": \"cid6IhuGpSIpuosGTGusYUc2g==\"\n },\n {\n \"name\": \"0529机器人 && 移动端中控问题跟进\",\n \"openConversationId\": \"cidXMfbCGTSiHzLLaRV5WDXgw==\"\n },\n {\n \"name\": \"念晨,伊玲,双数\",\n \"openConversationId\": \"cid/xM8a62lWiQGkfgLi4kfhA==\"\n },\n {\n \"name\": \"方齐,念晨,向尘\",\n \"openConversationId\": \"cidpG74/BF0DPbkIHS42XPZZg==\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1799 - }, - { - "service": "chat", - "command": "+chat-role-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-role-list --group cid3Jijzhe2aqs9ysOXjhi05g== --yes --format json", - "args": [ - "chat", - "+chat-role-list", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"roles\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 928 - }, - { - "service": "chat", - "command": "+chat-role-query-user", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-role-query-user --group cid3Jijzhe2aqs9ysOXjhi05g== --user '冬翔' --yes --format json", - "args": [ - "chat", - "+chat-role-query-user", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--user", - "冬翔", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"roles\": []\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 958 - }, - { - "service": "chat", - "command": "+chat-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-search --query '测试' --limit 10 --cursor 0 --exclude-muted --yes --format json", - "args": [ - "chat", - "+chat-search", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "0", - "--exclude-muted", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"groups\": [\n {\n \"channel\": false,\n \"createAt\": \"2026-06-09 09:21:07\",\n \"extension\": {\n \"newCSpaceIdIM\": \"29001572040\"\n },\n \"groupType\": \"INTERNAL_GROUP\",\n \"memberCount\": 4,\n \"openConversationId\": \"cidYmSrmbqMM/OseCzcs4cJdg==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"title\": \"测试群已废弃\"\n },\n {\n \"channel\": false,\n \"createAt\": \"2026-06-09 09:21:00\",\n \"extension\": {\n \"newCSpaceIdIM\": \"29001813316\"\n },\n \"groupType\": \"INTERNAL_GROUP\",\n \"memberCount\": 3,\n \"openConversationId\": \"cidSIMPP2TD6HZcn1nO0mVNoA==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"title\": \"测试群已废弃\"\n },\n {\n \"channel\": false,\n \"createAt\": \"2026-05-13 16:22:30\",\n \"extension\": {\n \"newCSpaceIdIM\": \"28540426059\"\n },\n \"groupType\": \"INTERNAL_GROUP\",\n \"memberCount\": 70,\n \"openConversationId\": \"cidU38vTWZbweyBRT71Md/KJQ==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UXSDzdMWL3Ls3VUvhTCwbT\",\n \"title\": \"测试项目沟通群\"\n },\n {\n \"channel\": false,\n \"createAt\": \"2026-04-24 11:49:36\",\n \"extension\": {\n \"newCSpaceIdIM\": \"28740101247\"\n },\n \"groupType\": \"INTERNAL_GROUP\",\n \"memberCount\": 2,\n \"openConversationId\": \"cidOpAz1EH8TLuD9H1Who48pw==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"title\": \"测试项目沟通群\"\n },\n {\n \"channel\": false,\n \"createAt\": \"2026-06-26 14:05:19\",\n \"extension\": {\n \"newCSpaceIdIM\": \"29101779299\"\n },\n \"groupType\": \"INTERNAL_GROUP\",\n \"memberCount\": 2,\n \"openConversationId\": \"cidp+Oj7m1gJnx+bCtTn3vhcQ==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"title\": \"Spur测试\"\n },\n {\n \"channel\": false,\n \"createAt\": \"2026-07-02 11:26:29\",\n \"extension\": {\n \"newCSpaceIdIM\": \"29137611383\"\n },\n \"groupType\": \"NEW_EXTERNAL_GROUP\",\n \"memberCount\": 1,\n \"openConversationId\": \"cidwnTdZmw2YMznZKLOmws9VQ==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"title\": \"中控测试\"\n },\n {\n \"channel\": false,\n \"createAt\": \"2026-06-09 11:03:12\",\n \"extension\": {\n \"newCSpaceIdIM\": \"29002815625\"\n },\n \"groupType\": \"INTERNAL_GROUP\",\n \"memberCount\": 2,\n \"openConversationId\": \"cid3hq0VEj6C6FtEtDsWCtNwg==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"title\": \"评测复盘讨论群-测试\"\n },\n {\n \"channel\": true,\n \"createAt\": \"2026-03-24 18:10:34\",\n \"extension\": {\n \"newCSpaceIdIM\": \"28494427717\"\n },\n \"groupType\": \"NEW_EXTERNAL_GROUP\",\n \"memberCount\": 2628,\n \"openConversationId\": \"cidm6kkku3iW/ddE/DuoWOEnQ==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3XSNFHTUgOrXFyf7hpLIgii2\",\n \"title\": \"悟空问题反馈群\"\n },\n {\n \"channel\": false,\n \"createAt\": \"2026-05-13 16:23:30\",\n \"extension\": {\n \"newCSpaceIdIM\": \"28540433569\"\n },\n \"groupType\": \"INTERNAL_GROUP\",\n \"memberCount\": 71,\n \"openConversationId\": \"cidzoNLz/wgp7gRm/mRph+MxA==\",\n \"ownerOpenDingtalkId\": \"D1jIqGatuH3UXSDzdMWL3Ls3VUvhTCwbT\",\n \"title\": \"日常消息同步群\"\n }\n ],\n \"hasMore\": true,\n \"nextCursor\": \"da8b2b5ea0a50c4ae90dcf38edde9f52425c3bb1dd7cb1eddf53e7db6b73b3c4299e03fd01b0cf917b20e2f46a4f5e13bb7a1bd722cf6c4a372c6971d3c37fb7a3301ae0e438d2556a38ae1fc9c6ee9c110732c3d5d829998a367dc7ebd3a0f639e2dc0841255be944cf07a406575e4cbee22cfca7f309a6bba4d6c35f7d3bd05ce9af894fd2f107f264c0c16d61f6c778e2c2e5c3ea05bc0f77ae6cd7bf402f77218ef7439e2483e5348b3cc40b6a28eeb87bc58925bff5a69d0b080d3818ef1eed93958ffb401cb286b4e885564950f46fa42ad85b4161fd5752f840865648c4e2a89a26f8e241ee476d17a8a2352feb4e1f7d97b28d69aeba429b1e6565fd9acc3e2aa71ec6c05d0a31d90c451aa3\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1176 - }, - { - "service": "chat", - "command": "+conversation-info", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +conversation-info --group cid3Jijzhe2aqs9ysOXjhi05g== --open-dingtalk-id x --yes --format json", - "args": [ - "chat", - "+conversation-info", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--open-dingtalk-id", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"conversationInfo\": {\n \"createAt\": \"2018-11-13 12:39:07\",\n \"extension\": {\n \"newCSpaceIdIM\": \"1090404636\"\n },\n \"memberCount\": 3,\n \"openConversationId\": \"cid3Jijzhe2aqs9ysOXjhi05g==\",\n \"ownerNick\": \"浅曦\",\n \"singleChat\": false,\n \"title\": \"浅曦-kida,Dennis,秋画\"\n }\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 942 - }, - { - "service": "chat", - "command": "+conversation-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +conversation-list --limit 10 --cursor 0 --exclude-muted --yes --format json", - "args": [ - "chat", - "+conversation-list", - "--limit", - "10", - "--cursor", - "0", - "--exclude-muted", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"conversations\": [\n {\n \"conversationName\": \"AI全栈\",\n \"openConversationId\": \"cidGAcU1FHD+D2n3W6d+SXuhw==\"\n },\n {\n \"conversationName\": \"念晨\",\n \"openConversationId\": \"cidLw95vHkBXGePhIxRvleOUOtxfWW7v5vcWfa2fdxfxyg=\"\n },\n {\n \"conversationName\": \"九韶\",\n \"openConversationId\": \"cidJdiIbpqCvEjbCuwHaHwR4A==\"\n },\n {\n \"conversationName\": \"战友会\",\n \"openConversationId\": \"cidK7s9XRz94DFkk4raOLduFw==\"\n },\n {\n \"conversationName\": \"生煎包\",\n \"openConversationId\": \"cid2CMDRvYP+Aw5ioOfP+se4+txfWW7v5vcWfa2fdxfxyg=\"\n },\n {\n \"conversationName\": \"Wukong & Real 日志群\",\n \"openConversationId\": \"ciddYsoBxGQrFWR5zJ8lnpPBg==\"\n },\n {\n \"conversationName\": \"粤腾\",\n \"openConversationId\": \"cid72F2hVHMoijoL1CvmWp1ImIo0AJKl0bni7qLL0B05PA=\"\n },\n {\n \"conversationName\": \"算法团队全员群\",\n \"openConversationId\": \"cidXMThCvAfn35/JRu/JOr6BA==\"\n },\n {\n \"conversationName\": \"汇量问题反馈✖️skill/mcp\",\n \"openConversationId\": \"cidTm/YDFJ/jgGFYFe3UF6dzA==\"\n },\n {\n \"conversationName\": \"亲橙park舒婷水果优惠1群\",\n \"openConversationId\": \"cid9LvgVwAlIvDBduIER4jFQg==\"\n }\n ],\n \"count\": 10\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2857 - }, - { - "service": "chat", - "command": "+conversation-list-top", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +conversation-list-top --limit 10 --cursor 0 --exclude-muted --yes --format json", - "args": [ - "chat", - "+conversation-list-top", - "--limit", - "10", - "--cursor", - "0", - "--exclude-muted", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"conversations\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1091 - }, - { - "service": "chat", - "command": "+messages-list-direct", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-list-direct --user 103262 --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json", - "args": [ - "chat", - "+messages-list-direct", - "--user", - "103262", - "--time", - "2026-07-15 10:00:00", - "--forward", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 4,\n \"messages\": [\n {\n \"createTime\": \"2026-07-15 11:02:04\",\n \"messageId\": \"msgmsdyW4xAGL0iXVc9mR4V0g==\",\n \"senderId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"text\": \"[文档](https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO)\\nDWS shortcut 修复复测 20260715:验证 doc +share-doc,请忽略。\"\n },\n {\n \"createTime\": \"2026-07-15 11:01:49\",\n \"messageId\": \"msgj2fRqVeyvOLxrZ5EntlMIA==\",\n \"senderId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"text\": \"DWS shortcut 修复复测 20260715:验证 chat +broadcast,请忽略。\"\n },\n {\n \"createTime\": \"2026-07-15 11:01:34\",\n \"messageId\": \"msgEuOor1PmFBNlx9M06N9z1Q==\",\n \"senderId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"text\": \"DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。\"\n },\n {\n \"createTime\": \"2026-07-15 10:12:31\",\n \"messageId\": \"msg4cdzVyqpmXc9WAxnR3lcYQ==\",\n \"senderId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"text\": \"-1\\nDWS shortcut 真实测试 20260715 日程,可忽略\\nAsia/Shanghai\\ndingtalk://dingtalkclient/action/switchtab?index=1&name=ding&type=calendar&targetDateTime=1784172600000\\n1\\n1784173500000\\n日历使用手册\\n0\\ndingtalk://dingtalkclient/page/calendar_detail?uniqueId=c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09&corpId=ding8196cd9a2b2405da24f2f5cc6abecb85&from=im_card&redirect_url=https%3A%2F%2Fn.dingtalk.com%2Fdingding%2Fcalendar%2Fdetail%2Findex.html%3Fdd_full_screen%3Dtrue%26dd_progress%3Dfalse%26dd_darkmode%3Dtrue%26%26dd_android_full%3Dtrue%26uniqueId%3Dc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09%26corpId%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26from%3Dim_card\\n0\\n0\\n了解详情\\n1784172600000\\n1\\n439446171\\n1784172600000\\n1784081621092b363beb2-0536-4131-b9ca-935571fca90d\\ndingtalk://dingtalkclient/page/link?url=https%3A%2F%2Falidocs.dingtalk.com%2Fi%2Fp%2FY7kmbokZp3pgGLq2%2Fdocs%2F7Y36k14mK9AV3pKq5zA1J5NqapjblR2D\\nfalse\\n0\\n512653609\\n999\\n1784173500000\\ndingtalk://dingtalkclient/page/calendar_detail?uniqueId=c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09&corpId=ding8196cd9a2b2405da24f2f5cc6abecb85&from=im_card&redirect_url=https%3A%2F%2Fn.dingtalk.com%2Fdingding%2Fcalendar%2Fdetail%2Findex.html%3Fdd_full_screen%3Dtrue%26dd_progress%3Dfalse%26dd_darkmode%3Dtrue%26%26dd_android_full%3Dtrue%26uniqueId%3Dc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09%26corpId%3Dding8196cd9a2b2405da24f2f5cc6abecb85%26from%3Dim_card\\nc0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1386 - }, - { - "service": "chat", - "command": "+messages-list-pin", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-list-pin --open-conversation-id cid3Jijzhe2aqs9ysOXjhi05g== --cursor 0 --size 1 --yes --format json", - "args": [ - "chat", - "+messages-list-pin", - "--open-conversation-id", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--cursor", - "0", - "--size", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"pins\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1105 - }, - { - "service": "chat", - "command": "+messages-list-unread-conversations", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-list-unread-conversations --count 1 --exclude-muted --yes --format json", - "args": [ - "chat", - "+messages-list-unread-conversations", - "--count", - "1", - "--exclude-muted", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"conversations\": [\n {\n \"conversationId\": \"ciddYsoBxGQrFWR5zJ8lnpPBg==\",\n \"title\": \"Wukong & Real 日志群\"\n }\n ],\n \"count\": 1\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1575 - }, - { - "service": "chat", - "command": "+my-groups", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +my-groups --type ALL --yes --format json", - "args": [ - "chat", - "+my-groups", - "--type", - "ALL", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"groups\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1499 - }, - { - "service": "chat", - "command": "+unread-chats", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +unread-chats --count 1 --exclude-muted --yes --format json", - "args": [ - "chat", - "+unread-chats", - "--count", - "1", - "--exclude-muted", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"conversations\": [\n {\n \"conversationId\": \"ciddYsoBxGQrFWR5zJ8lnpPBg==\",\n \"name\": \"Wukong & Real 日志群\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1649 - }, - { - "service": "contact", - "command": "+dept-members", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +dept-members --dept '模型算法' --yes --format json", - "args": [ - "contact", - "+dept-members", - "--dept", - "模型算法", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"deptUserList\": [\n {\n \"userInfo\": {\n \"name\": \"冯铖喆\",\n \"userId\": \"453559\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"何佳瑶\",\n \"userId\": \"548493\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"陈梦媛\",\n \"userId\": \"378607\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"张俊韶\",\n \"userId\": \"030386\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"陈邦杰\",\n \"userId\": \"489149\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"王昊东\",\n \"userId\": \"548778\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"岑锦锋\",\n \"userId\": \"547857\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"彭智韩\",\n \"userId\": \"478801\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"王迎湃\",\n \"userId\": \"543875\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"段旭彬\",\n \"userId\": \"550582\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"牛康佳\",\n \"userId\": \"484887\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"符咏畅\",\n \"userId\": \"519483\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"代成俊\",\n \"userId\": \"167095\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"黄渊卓\",\n \"userId\": \"519019\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"董鑫阳\",\n \"userId\": \"202397\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"崔佳骐\",\n \"userId\": \"534975\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"郑琪欣\",\n \"userId\": \"543310\"\n }\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1539 - }, - { - "service": "contact", - "command": "+list-dept-members", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +list-dept-members --depts x --yes --format json", - "args": [ - "contact", - "+list-dept-members", - "--depts", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"members\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 965 - }, - { - "service": "contact", - "command": "+list-followings", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +list-followings --yes --format json", - "args": [ - "contact", - "+list-followings", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 13,\n \"followings\": [\n {\n \"openDingTalkId\": \"D1jIqGatuH3UbjK1S0jAZJrukgiPdVtRlZ\"\n },\n {\n \"openDingTalkId\": \"DS8iSxaOjF5pjvmTc3wM85awiEiE\"\n },\n {\n \"openDingTalkId\": \"D1jIqGatuH3UrNUuSZbqakTnUpQ3uwcViS\"\n },\n {\n \"openDingTalkId\": \"D1jIqGatuH3X0M3DbwmJLFJDaK7t2NhxA\"\n },\n {\n \"openDingTalkId\": \"D51G4GOo4SMLvmTc3wM85awiEiE\"\n },\n {\n \"openDingTalkId\": \"DHpuMVMNL77HvmTc3wM85awiEiE\"\n },\n {\n \"openDingTalkId\": \"DO3r8ZwxEZ7iSvmTc3wM85awiEiE\"\n },\n {\n \"openDingTalkId\": \"DbQkLTHgGzOiSvmTc3wM85awiEiE\"\n },\n {\n \"openDingTalkId\": \"D1jIqGatuH3UgB25OaELcBlHqiPGB82YtU\"\n },\n {\n \"openDingTalkId\": \"D8y2OTDIEjFjWZ8DyfnKC9lug4rSiPJt8I\"\n },\n {\n \"openDingTalkId\": \"DepgurAiSOr3bvpshro641ZyKOpvhZI2tI\"\n },\n {\n \"openDingTalkId\": \"DtMlaaX4m2p8qiPOT4eDpd1MUBtiiFg6Xhx\"\n },\n {\n \"openDingTalkId\": \"D1jIqGatuH3U4ekOiiRj3wcvSfoaqqfvEb\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1658 - }, - { - "service": "contact", - "command": "+list-role-members", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +list-role-members --id DWSREALREADNOSUCHID0000000000000 --yes --format json", - "args": [ - "contact", - "+list-role-members", - "--id", - "DWSREALREADNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"members\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1162 - }, - { - "service": "contact", - "command": "+list-roles", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +list-roles --yes --format json", - "args": [ - "contact", - "+list-roles", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"roles\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1227 - }, - { - "service": "contact", - "command": "+list-sub-depts", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +list-sub-depts --dept 842379556 --yes --format json", - "args": [ - "contact", - "+list-sub-depts", - "--dept", - "842379556", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"depts\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1217 - }, - { - "service": "contact", - "command": "+lookup", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +lookup --name '冬翔' --yes --format json", - "args": [ - "contact", - "+lookup", - "--name", - "冬翔", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"result\": [\n {\n \"isAdmin\": true,\n \"orgEmployeeModel\": {\n \"depts\": [\n {\n \"deptId\": 996530034,\n \"deptName\": \"AI终端与创新\"\n }\n ],\n \"jobNumber\": \"103262\",\n \"labels\": [\n {\n \"groupName\": \"默认\",\n \"id\": 2679876691,\n \"name\": \"子管理员\",\n \"tagGroupCode\": \"2679876691\"\n },\n {\n \"groupName\": \"默认\",\n \"id\": 2679876693,\n \"name\": \"主管\",\n \"tagGroupCode\": \"2679876693\"\n }\n ],\n \"orgAuthEmail\": null,\n \"orgId\": null,\n \"orgMasterDisplayName\": \"朱鸿\",\n \"orgMasterUserId\": \"024083\",\n \"orgName\": \"钉钉\",\n \"orgTitle\": \"\",\n \"orgUserId\": \"103262\",\n \"orgUserName\": \"夏东翔\",\n \"positions\": [\n {\n \"deptId\": 996530034,\n \"isMain\": true,\n \"managerDisplayName\": null,\n \"managerStaffId\": null,\n \"title\": null,\n \"workStation\": \"浙江-杭州-阿里中心·杭州未科 \"\n }\n ]\n }\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1698 - }, - { - "service": "contact", - "command": "+me", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +me --yes --format json", - "args": [ - "contact", - "+me", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"dept\": \"模型算法\",\n \"mobile\": \"13161187007\",\n \"name\": \"董鑫阳\",\n \"org\": \"钉钉\",\n \"userId\": \"202397\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1072 - }, - { - "service": "contact", - "command": "+org", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +org --name '冬翔' --yes --format json", - "args": [ - "contact", - "+org", - "--name", - "冬翔", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"result\": {\n \"deptId\": 996530034,\n \"deptName\": \"AI终端与创新\",\n \"memberCount\": 5\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1627 - }, - { - "service": "contact", - "command": "+resolve-dept", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +resolve-dept --name '模型算法' --yes --format json", - "args": [ - "contact", - "+resolve-dept", - "--name", - "模型算法", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"deptId\": \"842379556\",\n \"name\": \"技术平台-模型算法\",\n \"resolved\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1186 - }, - { - "service": "contact", - "command": "+search-mobile", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +search-mobile --mobile 13000000000 --yes --format json", - "args": [ - "contact", - "+search-mobile", - "--mobile", - "13000000000", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"users\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1415 - }, - { - "service": "contact", - "command": "+search-user", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +search-user --query '测试' --yes --format json", - "args": [ - "contact", - "+search-user", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 4,\n \"users\": [\n {\n \"flowerName\": null,\n \"name\": null,\n \"openDingTalkId\": \"Dn3FQ9n3oCiiNviSya2usICjFug4rSiPJt8I\",\n \"title\": null,\n \"userId\": null\n },\n {\n \"flowerName\": null,\n \"name\": null,\n \"openDingTalkId\": \"Dxud965XFNRyGCOG0Bwk0pVug4rSiPJt8I\",\n \"title\": null,\n \"userId\": null\n },\n {\n \"flowerName\": null,\n \"name\": null,\n \"openDingTalkId\": \"D1jIqGatuH3VffnYwNdg1p2LlYiSf05oYii\",\n \"title\": null,\n \"userId\": null\n },\n {\n \"flowerName\": null,\n \"name\": \"测试\",\n \"openDingTalkId\": \"D1jIqGatuH3WSeoELOC89rgTx03b5vNii1\",\n \"title\": \"\",\n \"userId\": \"036230684122903146\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1419 - }, - { - "service": "contact", - "command": "+team", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +team --name '冬翔' --yes --format json", - "args": [ - "contact", - "+team", - "--name", - "冬翔", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"deptUserList\": [\n {\n \"userInfo\": {\n \"name\": \"黄文杰\",\n \"userId\": \"106261\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"夏东翔\",\n \"userId\": \"103262\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"何伟楠\",\n \"userId\": \"106201\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"汪鑫\",\n \"userId\": \"323179\"\n }\n },\n {\n \"userInfo\": {\n \"name\": \"刘光宇\",\n \"userId\": \"112161\"\n }\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1738 - }, - { - "service": "devapp", - "command": "+event-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +event-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --keyword '测试' --cursor '' --page-size 10 --yes --format json", - "args": [ - "devapp", - "+event-list", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--keyword", - "测试", - "--cursor", - "", - "--page-size", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"events\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1214 - }, - { - "service": "devapp", - "command": "+get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json", - "args": [ - "devapp", - "+get", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"appKey\": \"dingdtqbtxgtn2r5uyge\",\n \"appStatus\": \"normal\",\n \"canDelete\": true,\n \"canUpdate\": true,\n \"creator\": \"董鑫阳\",\n \"desc\": \"DTDennisLobster\",\n \"gmtCreate\": \"2026-03-06 10:41:40\",\n \"gmtModified\": \"2026-03-06 10:41:40\",\n \"icon\": \"@lADPDfmVQafoVxrMyMzI\",\n \"iconUrl\": \"https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg\",\n \"name\": \"DTDennisLobster\",\n \"supportHarmony\": false,\n \"unifiedAppId\": \"678f27ec-4339-49d8-9c49-371b284bf552\",\n \"versionStatus\": \"INIT\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1268 - }, - { - "service": "devapp", - "command": "+list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +list --name 'DWS shortcut 真实测试' --app-key x --app-group-id 1 --creator x --robot-name x --develop-type 1 --filter-cool-app 1 --sort-type x --sort-order x --cursor '' --page-size 10 --yes --format json", - "args": [ - "devapp", - "+list", - "--name", - "DWS shortcut 真实测试", - "--app-key", - "x", - "--app-group-id", - "1", - "--creator", - "x", - "--robot-name", - "x", - "--develop-type", - "1", - "--filter-cool-app", - "1", - "--sort-type", - "x", - "--sort-order", - "x", - "--cursor", - "", - "--page-size", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"apps\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 8524 - }, - { - "service": "devapp", - "command": "+member-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +member-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json", - "args": [ - "devapp", - "+member-list", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"members\": [\n {\n \"memberType\": \"OWNER\",\n \"name\": \"董鑫阳\",\n \"userId\": \"202397\"\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1404 - }, - { - "service": "devapp", - "command": "+permission-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +permission-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --keyword '测试' --auth-status ALL --cursor '' --page-size 10 --yes --format json", - "args": [ - "devapp", - "+permission-list", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--keyword", - "测试", - "--auth-status", - "ALL", - "--cursor", - "", - "--page-size", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"permissions\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1578 - }, - { - "service": "devapp", - "command": "+robot-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +robot-get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json", - "args": [ - "devapp", - "+robot-get", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"configured\": false,\n \"message\": \"应用未配置机器人。\",\n \"robotStatus\": \"UNCONFIGURED\",\n \"unifiedAppId\": \"678f27ec-4339-49d8-9c49-371b284bf552\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1204 - }, - { - "service": "devapp", - "command": "+version-check-approval", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +version-check-approval --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --version-id 4743accb-45e2-4bc9-8c96-74fc62ace2e8 --yes --format json", - "args": [ - "devapp", - "+version-check-approval", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--version-id", - "4743accb-45e2-4bc9-8c96-74fc62ace2e8", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"approvalCandidates\": [\n {\n \"mainAdmin\": false,\n \"name\": \"王军\",\n \"userId\": \"084896\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"尤坤\",\n \"userId\": \"425982\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"贺汝坤\",\n \"userId\": \"453209\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"倪爽\",\n \"userId\": \"443841\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"谢传贵\",\n \"userId\": \"014823\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"史伟民\",\n \"userId\": \"188288\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"郭迪\",\n \"userId\": \"452337\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"周子杰\",\n \"userId\": \"312753\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"刘畅\",\n \"userId\": \"068373\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"张磊\",\n \"userId\": \"371811\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"霍俊龙\",\n \"userId\": \"068428\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"李晓军\",\n \"userId\": \"004897\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"姚海龙\",\n \"userId\": \"107578\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王丽丽\",\n \"userId\": \"246929\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"李恺杰\",\n \"userId\": \"272720\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"夏东翔\",\n \"userId\": \"103262\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"朱鸿\",\n \"userId\": \"024083\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"叶冬\",\n \"userId\": \"371817\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"任卿\",\n \"userId\": \"006792\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"彭家俊\",\n \"userId\": \"225893\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"刘蓓蓓\",\n \"userId\": \"074268\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王耀波\",\n \"userId\": \"220629\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"夏烈阳\",\n \"userId\": \"176371\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"魏玉科\",\n \"userId\": \"061331\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"张晨阳\",\n \"userId\": \"244717\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"李祎嵩\",\n \"userId\": \"238863\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"吕思霖\",\n \"userId\": \"193021\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"宋宜涛\",\n \"userId\": \"037168\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"李红军\",\n \"userId\": \"133206\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"沈运亮\",\n \"userId\": \"101595\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"桂明月\",\n \"userId\": \"077725\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"吴芳祥\",\n \"userId\": \"074360\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"赵召航\",\n \"userId\": \"044473\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"黄文奇\",\n \"userId\": \"270175\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"董金威\",\n \"userId\": \"085729\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"孙博文\",\n \"userId\": \"034766\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"朱通\",\n \"userId\": \"327979\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王帅军\",\n \"userId\": \"061731\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王海旭\",\n \"userId\": \"273048\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"欧阳德仁\",\n \"userId\": \"167272\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"冯汝伟\",\n \"userId\": \"124456\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"刘啸天\",\n \"userId\": \"314418\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"杨期\",\n \"userId\": \"028611\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"宋华青\",\n \"userId\": \"054469\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"俞福春\",\n \"userId\": \"424920\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"汤素文\",\n \"userId\": \"088045\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王摧\",\n \"userId\": \"141842\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王鸣霏\",\n \"userId\": \"184163\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王兵\",\n \"userId\": \"135976\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"周剑云\",\n \"userId\": \"164217\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"沈国勋\",\n \"userId\": \"026247\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"高阿娜\",\n \"userId\": \"001423\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"王森\",\n \"userId\": \"045981\"\n },\n {\n \"mainAdmin\": false,\n \"name\": \"林朋\",\n \"userId\": \"236891\"\n }\n ],\n \"approvalMode\": \"SELECT_APPROVER\",\n \"approvalSubmitted\": false,\n \"publishable\": false,\n \"published\": false,\n \"requiresApproval\": true,\n \"sensitive\": false,\n \"unifiedAppId\": \"678f27ec-4339-49d8-9c49-371b284bf552\",\n \"version\": \"1.0.0\",\n \"versionId\": \"4743accb-45e2-4bc9-8c96-74fc62ace2e8\",\n \"versionStatus\": \"INIT\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2081 - }, - { - "service": "devapp", - "command": "+version-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +version-get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --version-id 4743accb-45e2-4bc9-8c96-74fc62ace2e8 --yes --format json", - "args": [ - "devapp", - "+version-get", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--version-id", - "4743accb-45e2-4bc9-8c96-74fc62ace2e8", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"abilityList\": [],\n \"approvalMode\": \"SELECT_APPROVER\",\n \"description\": \"1.0.0\",\n \"enterpriseSelfBuiltAudit\": false,\n \"gmtCreate\": \"2026-03-06 10:43:01\",\n \"requiresApproval\": true,\n \"scopeList\": [\n \"qyapi_robot_sendmsg\",\n \"Card.Instance.Write\",\n \"Card.Streaming.Write\"\n ],\n \"sensitiveScopeList\": [],\n \"unifiedAppId\": \"678f27ec-4339-49d8-9c49-371b284bf552\",\n \"version\": \"1.0.0\",\n \"versionId\": \"4743accb-45e2-4bc9-8c96-74fc62ace2e8\",\n \"versionStatus\": \"INIT\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1738 - }, - { - "service": "devapp", - "command": "+version-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +version-list --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --cursor '' --page-size 10 --yes --format json", - "args": [ - "devapp", - "+version-list", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--cursor", - "", - "--page-size", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 1,\n \"versions\": [\n {\n \"desc\": \"1.0.0\",\n \"gmtCreate\": 1772764981000,\n \"status\": \"INIT\",\n \"version\": \"1.0.0\",\n \"versionId\": \"4743accb-45e2-4bc9-8c96-74fc62ace2e8\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1349 - }, - { - "service": "devapp", - "command": "+version-status", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +version-status --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --version-id 4743accb-45e2-4bc9-8c96-74fc62ace2e8 --yes --format json", - "args": [ - "devapp", - "+version-status", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--version-id", - "4743accb-45e2-4bc9-8c96-74fc62ace2e8", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"unifiedAppId\": \"678f27ec-4339-49d8-9c49-371b284bf552\",\n \"version\": \"1.0.0\",\n \"versionId\": \"4743accb-45e2-4bc9-8c96-74fc62ace2e8\",\n \"versionStatus\": \"INIT\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2272 - }, - { - "service": "devapp", - "command": "+webapp-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +webapp-get --unified-app-id 678f27ec-4339-49d8-9c49-371b284bf552 --yes --format json", - "args": [ - "devapp", - "+webapp-get", - "--unified-app-id", - "678f27ec-4339-49d8-9c49-371b284bf552", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"configured\": false,\n \"message\": \"未配置网页应用入口,请使用 webapp config 设置 homepageUrl、pcHomepageUrl 或 ompUrl。\",\n \"unifiedAppId\": \"678f27ec-4339-49d8-9c49-371b284bf552\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1576 - }, - { - "service": "ding", - "command": "+list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test ding +list --cursor 0 --type ALL --yes --format json", - "args": [ - "ding", - "+list", - "--cursor", - "0", - "--type", - "ALL", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"dingMessages\": [\n {\n \"openDingId\": \"5D73E3AC29C780072D1CD56C6874ACC2\",\n \"sendTime\": \"2026-07-14 20:30:06\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"6DE6EC00AEBEF6839CA5F309371550DA\",\n \"sendTime\": \"2026-07-10 16:17:20\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"4DE0B0D90A94683CE1A9E12CBBB82883\",\n \"sendTime\": \"2026-07-10 12:38:47\",\n \"senderNick\": \"思敏\"\n },\n {\n \"openDingId\": \"B1CF538F2A3CAD8F78E863BCFF10E90F\",\n \"sendTime\": \"2026-07-10 11:47:07\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"72D79962A868374366AF4FCA6946D49F\",\n \"sendTime\": \"2026-07-10 11:44:27\",\n \"senderNick\": \"汀葻\"\n },\n {\n \"openDingId\": \"A290A8F6D35CC8E712E3E4C30D915263\",\n \"sendTime\": \"2026-07-10 11:10:52\",\n \"senderNick\": \"楠者\"\n },\n {\n \"openDingId\": \"7B7BA69E68F85A3948B70A1BD79C9DF2\",\n \"sendTime\": \"2026-07-09 20:43:36\",\n \"senderNick\": \"楠者\"\n },\n {\n \"openDingId\": \"0D59FF6F871900140BD65422184C410C\",\n \"sendTime\": \"2026-07-09 17:25:36\",\n \"senderNick\": \"楠者\"\n },\n {\n \"openDingId\": \"B6072FCC82D85A343EE7834A70293F09\",\n \"sendTime\": \"2026-07-09 16:37:54\",\n \"senderNick\": \"王玮(哈瑞)\"\n },\n {\n \"openDingId\": \"91DFBF0D54B7A0DCC8F7EAF58560586C\",\n \"sendTime\": \"2026-07-09 15:17:19\",\n \"senderNick\": \"楠者\"\n },\n {\n \"openDingId\": \"EA1F2A52C13954855A3D0D7A32A973F6\",\n \"sendTime\": \"2026-07-09 11:52:15\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"340C8FD58735886D44B5EA46921F8AAD\",\n \"sendTime\": \"2026-07-07 11:48:58\",\n \"senderNick\": \"穆伦\"\n },\n {\n \"openDingId\": \"A389AD2799303C13C15BB6EF014D3B39\",\n \"sendTime\": \"2026-07-07 11:34:34\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"A26385411682C6C5042D775B25DA8F08\",\n \"sendTime\": \"2026-07-06 11:37:05\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"C42A8B86D5453197F79645460E0B7A99\",\n \"sendTime\": \"2026-07-03 17:41:43\",\n \"senderNick\": \"楠者\"\n },\n {\n \"openDingId\": \"5AEF5D3CF756F23345F86FF5E7544E73\",\n \"sendTime\": \"2026-07-03 15:40:52\",\n \"senderNick\": \"楠者\"\n },\n {\n \"openDingId\": \"EA0E39C793A943797EEDEA9B00740A72\",\n \"sendTime\": \"2026-07-03 14:40:23\",\n \"senderNick\": \"粤腾\"\n },\n {\n \"openDingId\": \"753EC85C1FA6E50D82EB5C19940F340E\",\n \"sendTime\": \"2026-07-02 20:26:38\",\n \"senderNick\": \"思敏\"\n },\n {\n \"openDingId\": \"782B702452A410EC6D30ADC9D4E95FDB\",\n \"sendTime\": \"2026-07-02 09:49:45\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"983C2C5A65CAE1A86D8C6BC272E73D11\",\n \"sendTime\": \"2026-07-02 09:48:09\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"40B84F9DF18DE299C2B2D712E7D01114\",\n \"sendTime\": \"2026-06-29 17:59:37\",\n \"senderNick\": \"穆伦\"\n },\n {\n \"openDingId\": \"F799B63D8EDDB7915C80159BD355704F\",\n \"sendTime\": \"2026-06-25 19:53:17\",\n \"senderNick\": \"文靖\"\n },\n {\n \"openDingId\": \"33C1238264ACA098634569396F60EE7A\",\n \"sendTime\": \"2026-06-25 18:10:19\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"2A05EE1E37F7EB023C3146819156E9A8\",\n \"sendTime\": \"2026-06-25 11:49:24\",\n \"senderNick\": \"穆伦\"\n },\n {\n \"openDingId\": \"29FBFA46D6F07ED25378F2871E24A1DC\",\n \"sendTime\": \"2026-06-24 12:00:32\",\n \"senderNick\": \"云欢\"\n },\n {\n \"openDingId\": \"2CE4C4F3EE8C332884FA1673C064921C\",\n \"sendTime\": \"2026-06-22 20:47:14\",\n \"senderNick\": \"黎常\"\n },\n {\n \"openDingId\": \"8A2B1A1F7E508B2F08A52FEDD9365843\",\n \"sendTime\": \"2026-06-18 13:41:19\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"7F7671C7853D88C92DE6EE0A671CB57B\",\n \"sendTime\": \"2026-06-17 10:52:34\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"9539E8D27A51F204197E326F94650279\",\n \"sendTime\": \"2026-06-16 11:50:18\",\n \"senderNick\": \"穆伦\"\n },\n {\n \"openDingId\": \"DE9E8FAFC8125BC715B999AC4267E2CA\",\n \"sendTime\": \"2026-06-15 21:35:40\",\n \"senderNick\": \"西渐(主用钉)\"\n },\n {\n \"openDingId\": \"A5D83E673DC5A97F38D95F3E135C7824\",\n \"sendTime\": \"2026-06-12 22:08:36\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"5360613B1FC2389E8435CFABC57CC012\",\n \"sendTime\": \"2026-06-12 13:51:21\",\n \"senderNick\": \"鱼非\"\n },\n {\n \"openDingId\": \"3AB6395324A62CB2B4B513D0A02F67FA\",\n \"sendTime\": \"2026-06-11 11:23:39\",\n \"senderNick\": \"徐潇扬\"\n },\n {\n \"openDingId\": \"FB9FB2808D6ADF7464F5F1ACBA8A6704\",\n \"sendTime\": \"2026-06-11 09:51:55\",\n \"senderNick\": \"徐潇扬\"\n },\n {\n \"openDingId\": \"4D094E702716D4A46015CDC12B8F216B\",\n \"sendTime\": \"2026-06-10 22:04:05\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"74E2B3EB36EE99E6D88744F6B2C4C265\",\n \"sendTime\": \"2026-06-10 18:14:48\",\n \"senderNick\": \"古榕\"\n },\n {\n \"openDingId\": \"CAEF5614C47B65CDF10AED44BDFC7D9F\",\n \"sendTime\": \"2026-06-10 16:02:42\",\n \"senderNick\": \"徐潇扬\"\n },\n {\n \"openDingId\": \"F2C3A0F51AC502E84AECB932C227AAED\",\n \"sendTime\": \"2026-06-09 17:43:20\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"8AB2C3B891D931D8D650D41CA6205BCE\",\n \"sendTime\": \"2026-06-09 15:50:23\",\n \"senderNick\": \"可菡\"\n },\n {\n \"openDingId\": \"0456CE6A50FD2902B68D772DA37A96BC\",\n \"sendTime\": \"2026-06-09 09:45:43\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"6CB0E9E1D2CC344CB9217C97550BC6C6\",\n \"sendTime\": \"2026-06-08 21:42:37\",\n \"senderNick\": \"林弈\"\n },\n {\n \"openDingId\": \"0DEFB0942003A059C230BE1D262CAC8C\",\n \"sendTime\": \"2026-06-08 21:27:39\",\n \"senderNick\": \"林弈\"\n },\n {\n \"openDingId\": \"6B600C709E7468700F208B5DBB7ED888\",\n \"sendTime\": \"2026-06-08 15:42:05\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"B3502CC153C09A27AADE63A0BDF94C4A\",\n \"sendTime\": \"2026-06-05 17:38:44\",\n \"senderNick\": \"慧德\"\n },\n {\n \"openDingId\": \"07EC05F3E019605304D3C8216C6F7A32\",\n \"sendTime\": \"2026-06-05 17:19:52\",\n \"senderNick\": \"弈宇(Alpha)\"\n },\n {\n \"openDingId\": \"1C25016699ADE6DE66C6C9ECAE8C83B2\",\n \"sendTime\": \"2026-06-05 14:46:54\",\n \"senderNick\": \"弈宇(Alpha)\"\n },\n {\n \"openDingId\": \"AF79C95530607985A95866F7DE45F45E\",\n \"sendTime\": \"2026-06-05 14:39:48\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"4BFEE560E26AA24C1966A4D6EB722B89\",\n \"sendTime\": \"2026-06-05 09:28:42\",\n \"senderNick\": \"慧德\"\n },\n {\n \"openDingId\": \"B7F170045D3C6CC9D37DE356276BF084\",\n \"sendTime\": \"2026-06-04 23:07:57\",\n \"senderNick\": \"念晨\"\n },\n {\n \"openDingId\": \"A04A3D9659BD8FBDCF0C5E28EB2314F7\",\n \"sendTime\": \"2026-06-04 17:30:21\",\n \"senderNick\": \"念晨\"\n }\n ],\n \"hasMore\": true,\n \"nextCursor\": 1780565411433\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1459 - }, - { - "service": "doc", - "command": "+comment-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +comment-list --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --limit 10 --type global --yes --format json", - "args": [ - "doc", - "+comment-list", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--limit", - "10", - "--type", - "global", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"commentList\": [\n {\n \"commentKey\": \"1784082136009b316ba6c48ad41ad92a8b6905406a17c\",\n \"content\": \"DWS shortcut 真实测试 20260715:验证 doc +comment-create,可忽略。\",\n \"createTime\": 1784082136009,\n \"isGlobal\": true,\n \"isSolved\": false,\n \"quote\": null,\n \"updateTime\": 1784082136009\n },\n {\n \"commentKey\": \"1784082170012979f5c547f7a49e88a6c1dd718bbb304\",\n \"content\": \"DWS shortcut 真实测试 20260715:验证 doc +comment-reply,可忽略。\",\n \"createTime\": 1784082170012,\n \"isGlobal\": true,\n \"isSolved\": false,\n \"quote\": null,\n \"updateTime\": 1784082170012\n }\n ],\n \"hasMore\": false,\n \"logId\": \"213ee25c17840994077796280e0923\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2579 - }, - { - "service": "doc", - "command": "+export-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +export-get --job-id 29346731713 --yes --format json", - "args": [ - "doc", - "+export-get", - "--job-id", - "29346731713", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"downloadUrl\": \"[REDACTED_OSS_URL]\",\n \"jobId\": \"29346731713\",\n \"logId\": \"2132f5ca17840994092582121e08d9\",\n \"message\": \"Export job completed. The file is ready for download via the downloadUrl.\",\n \"status\": \"success\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1379 - }, - { - "service": "doc", - "command": "+export-submit", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +export-submit --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --export-format docx --yes --format json", - "args": [ - "doc", - "+export-submit", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--export-format", - "docx", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"jobId\": \"29346731713\",\n \"logId\": \"2132f5ca17840994104887005e08d8\",\n \"message\": \"Export job submitted successfully. Use query_export_job with jobId '29346731713' to check the export status and get the download link.\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1573 - }, - { - "service": "doc", - "command": "+find-doc", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +find-doc --query '测试' --limit 10 --yes --format json", - "args": [ - "doc", - "+find-doc", - "--query", - "测试", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 10,\n \"documents\": [\n {\n \"title\": \"DWS shortcut 真实测试文档 20260715\",\n \"token\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space\"\n },\n {\n \"title\": \"🧪测试 工作区目录扫描 - 2026-04-27 21:27:03\",\n \"token\": \"yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space\"\n },\n {\n \"title\": \"文件扫描测试 -20260414\",\n \"token\": \"3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space\"\n },\n {\n \"title\": \"文件扫描测试(25)\",\n \"token\": \"QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space\"\n },\n {\n \"title\": \"文件扫描测试(5)\",\n \"token\": \"Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space\"\n },\n {\n \"title\": \"文件扫描测试(24)\",\n \"token\": \"OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space\"\n },\n {\n \"title\": \"文件扫描测试(22)\",\n \"token\": \"OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space\"\n },\n {\n \"title\": \"文件扫描测试(26)(2)\",\n \"token\": \"l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space\"\n },\n {\n \"title\": \"悟空DWS全部测试账号信息\",\n \"token\": \"X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space\"\n },\n {\n \"title\": \"测试\",\n \"token\": \"1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE\",\n \"type\": \"\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2146 - }, - { - "service": "doc", - "command": "+list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +list --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --limit 10 --yes --format json", - "args": [ - "doc", - "+list", - "--folder", - "Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 10,\n \"nodes\": [\n {\n \"name\": \"DWS shortcut 真实测试文档 20260715(2)\",\n \"nodeId\": \"np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l?utm_scene=person_space\"\n },\n {\n \"name\": \"DWS shortcut 真实测试文档 20260715\",\n \"nodeId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space\"\n },\n {\n \"name\": \"DWS shortcut 真实测试文档 20260715(1)\",\n \"nodeId\": \"7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3?utm_scene=person_space\"\n },\n {\n \"name\": \"知识库对接调研问卷-供各业务系统填写\",\n \"nodeId\": \"R4GpnMqJzOP0oLp6skZ0Kmvp8Ke0xjE3\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/R4GpnMqJzOP0oLp6skZ0Kmvp8Ke0xjE3?utm_scene=person_space\"\n },\n {\n \"name\": \"无标题文档(2)\",\n \"nodeId\": \"X6GRezwJlYxMoDeBsgX95gYj8dqbropQ\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsgX95gYj8dqbropQ?utm_scene=person_space\"\n },\n {\n \"name\": \"hi\",\n \"nodeId\": \"1zknDm0WRz0NZeXQu01lGOdmWBQEx5rG\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/1zknDm0WRz0NZeXQu01lGOdmWBQEx5rG?utm_scene=person_space\"\n },\n {\n \"name\": \"T(2)\",\n \"nodeId\": \"dxXB52LJqwZrolvyFKLy25D9WqjMp697\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/dxXB52LJqwZrolvyFKLy25D9WqjMp697?utm_scene=person_space\"\n },\n {\n \"name\": \"T(1)\",\n \"nodeId\": \"mweZ92PV6O36dZbnsML6RyaAJxEKBD6p\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/mweZ92PV6O36dZbnsML6RyaAJxEKBD6p?utm_scene=person_space\"\n },\n {\n \"name\": \"T\",\n \"nodeId\": \"4lgGw3P8vwYmoljyFg90Z2X9W5daZ90D\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/4lgGw3P8vwYmoljyFg90Z2X9W5daZ90D?utm_scene=person_space\"\n },\n {\n \"name\": \"念晨与hugozhu的会话 2026年06月22日\",\n \"nodeId\": \"GZLxjv9VGGDA9Ro2u6yKZ7BxV6EDybno\",\n \"nodeType\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/GZLxjv9VGGDA9Ro2u6yKZ7BxV6EDybno?utm_scene=person_space\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2075 - }, - { - "service": "doc", - "command": "+search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +search --query '测试' --limit 10 --cursor 0 --yes --format json", - "args": [ - "doc", - "+search", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 10,\n \"documents\": [\n {\n \"docType\": \"adoc\",\n \"modifiedTime\": null,\n \"name\": \"DWS shortcut 真实测试文档 20260715\",\n \"nodeId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space\"\n },\n {\n \"docType\": \"able\",\n \"modifiedTime\": null,\n \"name\": \"🧪测试 工作区目录扫描 - 2026-04-27 21:27:03\",\n \"nodeId\": \"yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space\"\n },\n {\n \"docType\": \"able\",\n \"modifiedTime\": null,\n \"name\": \"文件扫描测试 -20260414\",\n \"nodeId\": \"3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space\"\n },\n {\n \"docType\": \"able\",\n \"modifiedTime\": null,\n \"name\": \"文件扫描测试(25)\",\n \"nodeId\": \"QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space\"\n },\n {\n \"docType\": \"able\",\n \"modifiedTime\": null,\n \"name\": \"文件扫描测试(5)\",\n \"nodeId\": \"Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space\"\n },\n {\n \"docType\": \"able\",\n \"modifiedTime\": null,\n \"name\": \"文件扫描测试(24)\",\n \"nodeId\": \"OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space\"\n },\n {\n \"docType\": \"able\",\n \"modifiedTime\": null,\n \"name\": \"文件扫描测试(22)\",\n \"nodeId\": \"OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space\"\n },\n {\n \"docType\": \"able\",\n \"modifiedTime\": null,\n \"name\": \"文件扫描测试(26)(2)\",\n \"nodeId\": \"l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space\"\n },\n {\n \"docType\": \"adoc\",\n \"modifiedTime\": null,\n \"name\": \"悟空DWS全部测试账号信息\",\n \"nodeId\": \"X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space\"\n },\n {\n \"docType\": \"folder\",\n \"modifiedTime\": null,\n \"name\": \"测试\",\n \"nodeId\": \"1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1911 - }, - { - "service": "doc", - "command": "+template-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +template-list --source MY --limit 10 --cursor 0 --yes --format json", - "args": [ - "doc", - "+template-list", - "--source", - "MY", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"hasMore\": false,\n \"logId\": \"213ee25c17840994183018652e08e2\",\n \"message\": \"成功获取 3 个模版。\",\n \"success\": true,\n \"templates\": [\n {\n \"coverUrl\": \"[REDACTED_OSS_URL]\",\n \"description\": null,\n \"templateId\": \"494040872345\",\n \"title\": \"接口定义模版v1.0\",\n \"usedCount\": null\n },\n {\n \"coverUrl\": \"[REDACTED_OSS_URL]\",\n \"description\": null,\n \"templateId\": \"494801282345\",\n \"title\": \"接口定义与描述模版\",\n \"usedCount\": null\n },\n {\n \"coverUrl\": \"[REDACTED_OSS_URL]\",\n \"description\": null,\n \"templateId\": \"111001402345\",\n \"title\": \"2023届钉钉音视频春招海报\",\n \"usedCount\": null\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1370 - }, - { - "service": "doc", - "command": "+template-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +template-search --query '测试' --source MY --limit 10 --cursor 0 --yes --format json", - "args": [ - "doc", - "+template-search", - "--query", - "测试", - "--source", - "MY", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"hasMore\": false,\n \"logId\": \"213ee25c17840994199004331e08a0\",\n \"message\": \"未找到与「测试」相关的模版,请尝试其他关键词。\",\n \"success\": true,\n \"templates\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1576 - }, - { - "service": "doc", - "command": "+version-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +version-list --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --limit 10 --cursor 0 --yes --format json", - "args": [ - "doc", - "+version-list", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"hasMore\": false,\n \"success\": true,\n \"versions\": [\n {\n \"createTime\": \"2026-07-15T10:21:47+08:00\",\n \"type\": \"AUTO_SAVE\",\n \"updateTime\": \"2026-07-15T10:21:47+08:00\",\n \"userId\": \"202397\",\n \"version\": 0\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2069 - }, - { - "service": "drive", - "command": "+find-file", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test drive +find-file --query '测试' --yes --format json", - "args": [ - "drive", - "+find-file", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"files\": [\n {\n \"dentryId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"fileSize\": 0,\n \"name\": \"DWS shortcut 真实测试文档 20260715\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r\",\n \"fileSize\": 0,\n \"name\": \"🧪测试 工作区目录扫描 - 2026-04-27 21:27:03\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm\",\n \"fileSize\": 0,\n \"name\": \"文件扫描测试 -20260414\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N\",\n \"fileSize\": 0,\n \"name\": \"文件扫描测试(25)\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq\",\n \"fileSize\": 0,\n \"name\": \"文件扫描测试(5)\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4\",\n \"fileSize\": 0,\n \"name\": \"文件扫描测试(24)\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4\",\n \"fileSize\": 0,\n \"name\": \"文件扫描测试(22)\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4\",\n \"fileSize\": 0,\n \"name\": \"文件扫描测试(26)(2)\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l\",\n \"fileSize\": 0,\n \"name\": \"DWS shortcut 真实测试文档 20260715(2)\",\n \"type\": \"ALIDOC\"\n },\n {\n \"dentryId\": \"7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3\",\n \"fileSize\": 0,\n \"name\": \"DWS shortcut 真实测试文档 20260715(1)\",\n \"type\": \"ALIDOC\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1913 - }, - { - "service": "drive", - "command": "+info", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test drive +info --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --space-id 1 --yes --format json", - "args": [ - "drive", - "+info", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--space-id", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"createTime\": 1784082107000,\n \"dentryId\": \"228840832141\",\n \"docUrl\": \"https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"extension\": \"adoc\",\n \"fileId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"fileSize\": 0,\n \"message\": \"该文件属于钉钉文档(在线文档/表格/脑图等),通过钉盘接口获取的元数据(如文件名称)可能不准确。建议使用钉钉文档相关接口获取更准确的文档信息。\",\n \"modifyTime\": 1784082137000,\n \"name\": \"7691256802.adoc\",\n \"path\": \"/6342122716/7691256801/7691256802.adoc\",\n \"status\": \"NORMAL\",\n \"type\": \"ALIDOC\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2605 - }, - { - "service": "drive", - "command": "+recent", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test drive +recent --operate-type 1 --creator-type 1 --limit 10 --cursor 0 --yes --format json", - "args": [ - "drive", - "+recent", - "--operate-type", - "1", - "--creator-type", - "1", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"hasMore\": false,\n \"items\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2087 - }, - { - "service": "drive", - "command": "+search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test drive +search --query '测试' --target file --file-types alidoc --extensions x --creator-uids x --created-from 1 --created-to 1 --modified-from 1 --modified-to 1 --limit 10 --cursor 0 --yes --format json", - "args": [ - "drive", - "+search", - "--query", - "测试", - "--target", - "file", - "--file-types", - "alidoc", - "--extensions", - "x", - "--creator-uids", - "x", - "--created-from", - "1", - "--created-to", - "1", - "--modified-from", - "1", - "--modified-to", - "1", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"files\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2091 - }, - { - "service": "drive", - "command": "+search-docs", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test drive +search-docs --query '测试' --limit 10 --yes --format json", - "args": [ - "drive", - "+search-docs", - "--query", - "测试", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 10,\n \"docs\": [\n {\n \"name\": \"DWS shortcut 真实测试文档 20260715\",\n \"nodeId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO?utm_scene=person_space\"\n },\n {\n \"name\": \"🧪测试 工作区目录扫描 - 2026-04-27 21:27:03\",\n \"nodeId\": \"yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/yQod3RxJKQz4jm9xS4kw7dnmWkb4Mw9r?utm_scene=person_space\"\n },\n {\n \"name\": \"文件扫描测试 -20260414\",\n \"nodeId\": \"3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/3NwLYZXWyrmRolXLcZ7QaMDkVkyEqBQm?utm_scene=person_space\"\n },\n {\n \"name\": \"文件扫描测试(25)\",\n \"nodeId\": \"QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/QPGYqjpJYRyab0nxsZ7a9L5R8akx1Z5N?utm_scene=person_space\"\n },\n {\n \"name\": \"文件扫描测试(5)\",\n \"nodeId\": \"Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/Exel2BLV5gOAdXr1umNwXGBA8gk9rpMq?utm_scene=person_space\"\n },\n {\n \"name\": \"文件扫描测试(24)\",\n \"nodeId\": \"OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzqYlPzmJzN67Mw4?utm_scene=person_space\"\n },\n {\n \"name\": \"文件扫描测试(22)\",\n \"nodeId\": \"OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/OG9lyrgJPL7M2ONQSzQ4k0bRJzN67Mw4?utm_scene=person_space\"\n },\n {\n \"name\": \"文件扫描测试(26)(2)\",\n \"nodeId\": \"l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/l6Pm2Db8DQyAKmGkS90GX4ndWxLq0Ee4?utm_scene=person_space\"\n },\n {\n \"name\": \"悟空DWS全部测试账号信息\",\n \"nodeId\": \"X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ\",\n \"type\": \"file\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/X6GRezwJlYxMoDeBsQkxqNQ38dqbropQ?utm_scene=team_space\"\n },\n {\n \"name\": \"测试\",\n \"nodeId\": \"1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE\",\n \"type\": \"folder\",\n \"url\": \"https://alidocs.dingtalk.com/i/nodes/1OQX0akWmBejogd0TvQ7oN4oVGlDd3mE?utm_scene=team_space\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2507 - }, - { - "service": "mail", - "command": "+contact-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +contact-list --email xinyang.dxy@alibaba-inc.com --limit 10 --cursor '' --yes --format json", - "args": [ - "mail", - "+contact-list", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--limit", - "10", - "--cursor", - "", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"contacts\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3284 - }, - { - "service": "mail", - "command": "+find-mail-user", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +find-mail-user --query '董鑫阳' --limit 10 --yes --format json", - "args": [ - "mail", - "+find-mail-user", - "--query", - "董鑫阳", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 1,\n \"users\": [\n {\n \"email\": \"xinyang.dxy@alibaba-inc.com\",\n \"employeeNo\": \"202397\",\n \"id\": \"----.l------oBfPl:0:----HI-----HWk9nI\",\n \"jobTitle\": \"\",\n \"name\": \"董鑫阳\",\n \"nickname\": \"念晨\",\n \"workLocation\": \"浙江-杭州\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3666 - }, - { - "service": "mail", - "command": "+folder-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +folder-list --email xinyang.dxy@alibaba-inc.com --folder 2 --yes --format json", - "args": [ - "mail", - "+folder-list", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--folder", - "2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"folders\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3643 - }, - { - "service": "mail", - "command": "+recent-mail", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +recent-mail --limit 10 --email xinyang.dxy@alibaba-inc.com --folder 2 --yes --format json", - "args": [ - "mail", - "+recent-mail", - "--limit", - "10", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--folder", - "2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 10,\n \"mails\": [\n {\n \"date\": \"2026-07-15T06:37:28Z\",\n \"from\": [\n \"work@alibaba-inc.com\"\n ],\n \"subject\": \"牛康佳(随风)的数据权限申请流程 / NIU, Kangjia's apply data permission \",\n \"threadId\": \"DzzzzyJeBJL---.iLmnRnm\"\n },\n {\n \"date\": \"2026-07-15T06:35:49Z\",\n \"from\": [\n \"work@alibaba-inc.com\"\n ],\n \"subject\": \"changefree:diamond应用【albert-agent-2】变更 \",\n \"threadId\": \"DzzzzyJo3ZK---.hyYf9dO\"\n },\n {\n \"date\": \"2026-07-15T06:00:39Z\",\n \"from\": [\n \"安全服务中心 \"\n ],\n \"subject\": \"您的名下有1个风险尚未修复,请立即修复! You have 1 vulnerabilities that have not been fixed, please fix them as soon as possible!\",\n \"threadId\": \"DzzzzyKaAwA---.g643XXa\"\n },\n {\n \"date\": \"2026-07-15T05:48:41Z\",\n \"from\": [\n \"aone@alibaba-inc.com \"\n ],\n \"subject\": \"[Aone]应用albert-agent-2预发发布成功\",\n \"threadId\": \"DzzzzyJi7OS---.iCFAI0A\"\n },\n {\n \"date\": \"2026-07-15T02:54:00Z\",\n \"from\": [\n \"work@alibaba-inc.com\"\n ],\n \"subject\": \"代成俊(璟琦)的数据权限申请流程 / DAI, Chengjun's apply data permission \",\n \"threadId\": \"DzzzzyKTIMD---.gOEBekI\"\n },\n {\n \"date\": \"2026-07-15T02:51:12Z\",\n \"from\": [\n \"work@alibaba-inc.com\"\n ],\n \"subject\": \"陈梦媛(星莫)的数据权限申请流程 / CHEN, Mengyuan's apply data permission \",\n \"threadId\": \"DzzzzyKIHke---.goV93L.\"\n },\n {\n \"date\": \"2026-07-15T02:06:21Z\",\n \"from\": [\n \"dingtalk_recruitment@alibaba-inc.com\"\n ],\n \"subject\": \"【校招】简历即将超期/[Campus Recruitment] Resumes Are About to Expire\",\n \"threadId\": \"DzzzzyJjBhI---.i911RPE\"\n },\n {\n \"date\": \"2026-07-15T02:00:05Z\",\n \"from\": [\n \"dingtalk_recruitment@alibaba-inc.com\"\n ],\n \"subject\": \"【校招】2026-07-15 面试邀约任务通知/【Campus Recruitment】 2026-07-15 Interview Invitation Task Notice\",\n \"threadId\": \"DzzzzyJeFNO---.iLVkwOD\"\n },\n {\n \"date\": \"2026-07-15T01:42:44Z\",\n \"from\": [\n \"ATA技小蜜 \"\n ],\n \"subject\": \"【ATA每日精选】【天猫数字分身】(上篇) - AI Native 组织协作与研发的暗物质\",\n \"threadId\": \"DzzzzyJeFdf---.iLWDlI4\"\n },\n {\n \"date\": \"2026-07-14T18:24:45Z\",\n \"from\": [\n \"postmaster@alibaba-inc.com\"\n ],\n \"subject\": \"垃圾邮件提醒2026年7月15日\",\n \"threadId\": \"DzzzzyJeM2G---.iLDMDRq\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2724 - }, - { - "service": "mail", - "command": "+search-mail", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +search-mail --query 'subject:测试' --email xinyang.dxy@alibaba-inc.com --size 10 --yes --format json", - "args": [ - "mail", - "+search-mail", - "--query", - "subject:测试", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--size", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"email\": \"xinyang.dxy@alibaba-inc.com\",\n \"messages\": [\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNklS\",\n \"subject\": \"[Aone]实验室[lippi-calladmin单元测试]的构建[#1723]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkml\",\n \"subject\": \"[Aone]实验室[mozi-engine-admin单元测试-Jacoco]的构建[#2611]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNknb\",\n \"subject\": \"[Aone]实验室[lippi-calladmin单元测试]的构建[#1646]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkoy\",\n \"subject\": \"[Aone]实验室[mozi-engine-admin单元测试-Jacoco]的构建[#2609]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkp.\",\n \"subject\": \"[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1498]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkp0\",\n \"subject\": \"[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1497]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkpJ\",\n \"subject\": \"[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1493]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkpL\",\n \"subject\": \"[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1492]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkph\",\n \"subject\": \"[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1491]失败\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkpj\",\n \"subject\": \"[Aone]实验室[mozi-channel-admin-单元测试]的构建[#1490]失败\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1985 - }, - { - "service": "mail", - "command": "+tag-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +tag-list --email xinyang.dxy@alibaba-inc.com --yes --format json", - "args": [ - "mail", - "+tag-list", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 3,\n \"tags\": [\n {\n \"id\": \"1\",\n \"name\": \"Flagged\",\n \"parentId\": \"0\"\n },\n {\n \"id\": \"2\",\n \"name\": \"Completed\",\n \"parentId\": \"0\"\n },\n {\n \"id\": \"3\",\n \"name\": \"task\",\n \"parentId\": \"0\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2606 - }, - { - "service": "mail", - "command": "+template-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +template-list --email xinyang.dxy@alibaba-inc.com --limit 10 --cursor '' --yes --format json", - "args": [ - "mail", - "+template-list", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--limit", - "10", - "--cursor", - "", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"templates\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3083 - }, - { - "service": "mail", - "command": "+thread-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +thread-list --email xinyang.dxy@alibaba-inc.com --folder 2 --limit 10 --cursor '' --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --ascending --yes --format json", - "args": [ - "mail", - "+thread-list", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--folder", - "2", - "--limit", - "10", - "--cursor", - "", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--ascending", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"threads\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 6434 - }, - { - "service": "mail", - "command": "+unread-mail", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +unread-mail --email xinyang.dxy@alibaba-inc.com --size 10 --yes --format json", - "args": [ - "mail", - "+unread-mail", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--size", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"email\": \"xinyang.dxy@alibaba-inc.com\",\n \"messages\": [\n {\n \"date\": null,\n \"from\": \"work@alibaba-inc.com\",\n \"messageId\": \"DzzzzzzNkk0\",\n \"subject\": \"牛康佳(随风)的数据权限申请流程 / NIU, Kangjia's apply data permission \"\n },\n {\n \"date\": null,\n \"from\": \"work@alibaba-inc.com\",\n \"messageId\": \"DzzzzzzNkk1\",\n \"subject\": \"changefree:diamond应用【albert-agent-2】变更 \"\n },\n {\n \"date\": null,\n \"from\": \"安全服务中心 \",\n \"messageId\": \"DzzzzzzNkk2\",\n \"subject\": \"您的名下有1个风险尚未修复,请立即修复! You have 1 vulnerabilities that have not been fixed, please fix them as soon as possible!\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkk3\",\n \"subject\": \"[Aone]应用albert-agent-2预发发布成功\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkk4\",\n \"subject\": \"[Aone]应用albert-agent-2预发发布成功\"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkk5\",\n \"subject\": \"[Aone]应用albert-agent-2预发发布成功\"\n },\n {\n \"date\": null,\n \"from\": \"work@alibaba-inc.com\",\n \"messageId\": \"DzzzzzzNkk6\",\n \"subject\": \"代成俊(璟琦)的数据权限申请流程 / DAI, Chengjun's apply data permission \"\n },\n {\n \"date\": null,\n \"from\": \"work@alibaba-inc.com\",\n \"messageId\": \"DzzzzzzNkk7\",\n \"subject\": \"陈梦媛(星莫)的数据权限申请流程 / CHEN, Mengyuan's apply data permission \"\n },\n {\n \"date\": null,\n \"from\": \"aone@alibaba-inc.com \",\n \"messageId\": \"DzzzzzzNkk8\",\n \"subject\": \"[Aone]应用albert-agent-2预发发布成功\"\n },\n {\n \"date\": null,\n \"from\": \"dingtalk_recruitment@alibaba-inc.com\",\n \"messageId\": \"DzzzzzzNkk9\",\n \"subject\": \"【校招】简历即将超期/[Campus Recruitment] Resumes Are About to Expire\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3469 - }, - { - "service": "mail", - "command": "+user-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test mail +user-search --keyword '董鑫阳' --employee-no 202397 --email xinyang.dxy@alibaba-inc.com --cursor '' --limit 10 --yes --format json", - "args": [ - "mail", - "+user-search", - "--keyword", - "董鑫阳", - "--employee-no", - "202397", - "--email", - "xinyang.dxy@alibaba-inc.com", - "--cursor", - "", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 1,\n \"users\": [\n {\n \"email\": \"xinyang.dxy@alibaba-inc.com\",\n \"employeeNo\": \"202397\",\n \"name\": \"董鑫阳\",\n \"userId\": \"----.l------oBfPl:0:----HI-----HWk9nI\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2836 - }, - { - "service": "minutes", - "command": "+detail", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test minutes +detail --id DWSREALREADNOSUCHID0000000000000 --artifacts basic --direction older --yes --format json", - "args": [ - "minutes", - "+detail", - "--id", - "DWSREALREADNOSUCHID0000000000000", - "--artifacts", - "basic", - "--direction", - "older", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"basic\": {\n \"error\": \"[UNCLASSIFIED] taskUuid is invalid\\n hint: Use --verbose for detailed error logs\"\n },\n \"taskUuid\": \"DWSREALREADNOSUCHID0000000000000\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2608 - }, - { - "service": "minutes", - "command": "+list-all", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test minutes +list-all --query '测试' --limit 10 --cursor 0 --yes --format json", - "args": [ - "minutes", - "+list-all", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"minutes\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 23790 - }, - { - "service": "minutes", - "command": "+list-mine", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test minutes +list-mine --query '测试' --limit 10 --cursor 0 --yes --format json", - "args": [ - "minutes", - "+list-mine", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"minutes\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 27516 - }, - { - "service": "oa", - "command": "+list-cc", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test oa +list-cc --page 1 --limit 10 --query '测试' --yes --format json", - "args": [ - "oa", - "+list-cc", - "--page", - "1", - "--limit", - "10", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"instances\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 8468 - }, - { - "service": "oa", - "command": "+list-executed", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test oa +list-executed --page 1 --limit 10 --query '测试' --yes --format json", - "args": [ - "oa", - "+list-executed", - "--page", - "1", - "--limit", - "10", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"instances\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 29589 - }, - { - "service": "oa", - "command": "+list-forms", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test oa +list-forms --cursor 0 --limit 10 --yes --format json", - "args": [ - "oa", - "+list-forms", - "--cursor", - "0", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"forms\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 25611 - }, - { - "service": "oa", - "command": "+list-pending", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test oa +list-pending --start 1783494851348 --end 1784099651348 --page 1 --limit 10 --query '测试' --yes --format json", - "args": [ - "oa", - "+list-pending", - "--start", - "1783494851348", - "--end", - "1784099651348", - "--page", - "1", - "--limit", - "10", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"instances\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 8328 - }, - { - "service": "oa", - "command": "+list-submitted", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test oa +list-submitted --page 1 --limit 10 --query '测试' --yes --format json", - "args": [ - "oa", - "+list-submitted", - "--page", - "1", - "--limit", - "10", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"instances\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 12793 - }, - { - "service": "oa", - "command": "+my-initiated", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test oa +my-initiated --query '测试' --page 1 --limit 10 --yes --format json", - "args": [ - "oa", - "+my-initiated", - "--query", - "测试", - "--page", - "1", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"result\": {\n \"values\": []\n },\n \"success\": \"true\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3564 - }, - { - "service": "oa", - "command": "+search-forms", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test oa +search-forms --query '测试' --yes --format json", - "args": [ - "oa", - "+search-forms", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"forms\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1759 - }, - { - "service": "report", - "command": "+inbox-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test report +inbox-list --start 2026-07-08T15:14:39+08:00 --end 2026-07-15T15:14:39+08:00 --cursor 0 --size 1 --sender-user-ids x --yes --format json", - "args": [ - "report", - "+inbox-list", - "--start", - "2026-07-08T15:14:39+08:00", - "--end", - "2026-07-15T15:14:39+08:00", - "--cursor", - "0", - "--size", - "1", - "--sender-user-ids", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"reports\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2130 - }, - { - "service": "report", - "command": "+outbox-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test report +outbox-list --cursor 0 --size 1 --start 2026-07-08T15:14:41+08:00 --end 2026-07-15T15:14:41+08:00 --modified-start 2026-07-08T15:14:41+08:00 --modified-end 2026-07-15T15:14:41+08:00 --yes --format json", - "args": [ - "report", - "+outbox-list", - "--cursor", - "0", - "--size", - "1", - "--start", - "2026-07-08T15:14:41+08:00", - "--end", - "2026-07-15T15:14:41+08:00", - "--modified-start", - "2026-07-08T15:14:41+08:00", - "--modified-end", - "2026-07-15T15:14:41+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"reports\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1584 - }, - { - "service": "sheet", - "command": "+read", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test sheet +read --node DWSREALREADNOSUCHID0000000000000 --sheet-id x --range x --value-render-option formatted_value --yes --format json", - "args": [ - "sheet", - "+read", - "--node", - "DWSREALREADNOSUCHID0000000000000", - "--sheet-id", - "x", - "--range", - "x", - "--value-render-option", - "formatted_value", - "--yes", - "--format", - "json" - ], - "stdout": "null", - "stderr": "", - "exit_code": 0, - "duration_ms": 1589 - }, - { - "service": "todo", - "command": "+created-todos", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +created-todos --yes --format json", - "args": [ - "todo", - "+created-todos", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"created\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1733 - }, - { - "service": "todo", - "command": "+get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +get --task-id 55119034912 --yes --format json", - "args": [ - "todo", - "+get", - "--task-id", - "55119034912", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"todoDetailModel\": {\n \"activities\": [\n {\n \"action\": \"task.self.done\",\n \"activityId\": \"14314572403\",\n \"creatorId\": 5255888716\n },\n {\n \"action\": \"task.create\",\n \"activityId\": \"14178655650\",\n \"creatorId\": 2669375904\n }\n ],\n \"bizTag\": \"tb_group_task\",\n \"createdTime\": 1784017216974,\n \"creatorId\": \"2669375904\",\n \"creatorInfo\": {\n \"name\": \"李龙\",\n \"nick\": \"粤腾\",\n \"userId\": 2669375904\n },\n \"detailUrl\": {\n \"appUrl\": \"https://n.dingtalk.com/dingding/dd-todo/detail/index.html?dd_darkmode=true\\u0026android_scroll=false\\u0026dd_mini_app_id=5000000004746689\\u0026dt_editor=true\\u0026dd_full_screen=true\\u0026showmenu=false\\u0026dd_progress=false\\u0026dd_amplify=false\\u0026newPage=true\\u0026at_iframe=1\\u0026from=homePage\\u0026taskId=55119034912\\u0026bizTag=tb_group_task\\u0026cid=75985350515#/detail\",\n \"pcUrl\": \"https://n.dingtalk.com/dingding/dd-todo/detail/index.html?dd_darkmode=true\\u0026android_scroll=false\\u0026dd_mini_app_id=5000000004746689\\u0026dt_editor=true\\u0026dd_full_screen=true\\u0026showmenu=false\\u0026dd_progress=false\\u0026dd_amplify=false\\u0026newPage=true\\u0026at_iframe=1\\u0026from=homePage\\u0026taskId=55119034912\\u0026bizTag=tb_group_task\\u0026cid=75985350515#/detail\"\n },\n \"dueTime\": 1784044740651,\n \"executorIds\": [\n \"41129894\",\n \"5255888716\",\n \"447869\",\n \"512653609\",\n \"2669375904\"\n ],\n \"executorInfos\": [\n {\n \"name\": \"梓寻\",\n \"nick\": \"梓寻\",\n \"userId\": 41129894\n },\n {\n \"name\": \"陈绪\",\n \"nick\": \"陈叙\",\n \"userId\": 5255888716\n },\n {\n \"name\": \"徐洪鑫\",\n \"nick\": \"崇幻\",\n \"userId\": 447869\n },\n {\n \"name\": \"董鑫阳\",\n \"nick\": \"念晨\",\n \"userId\": 512653609\n },\n {\n \"name\": \"李龙\",\n \"nick\": \"粤腾\",\n \"userId\": 2669375904\n }\n ],\n \"finishTime\": 0,\n \"isDone\": false,\n \"modifiedTime\": 1784017216974,\n \"modifierId\": \"2669375904\",\n \"orgId\": 439446171,\n \"participantIds\": [\n \"41129894\",\n \"5255888716\",\n \"447869\",\n \"512653609\",\n \"2669375904\"\n ],\n \"participantInfos\": [\n {\n \"name\": \"梓寻\",\n \"nick\": \"梓寻\",\n \"userId\": 41129894\n },\n {\n \"name\": \"陈绪\",\n \"nick\": \"陈叙\",\n \"userId\": 5255888716\n },\n {\n \"name\": \"徐洪鑫\",\n \"nick\": \"崇幻\",\n \"userId\": 447869\n },\n {\n \"name\": \"董鑫阳\",\n \"nick\": \"念晨\",\n \"userId\": 512653609\n },\n {\n \"name\": \"李龙\",\n \"nick\": \"粤腾\",\n \"userId\": 2669375904\n }\n ],\n \"priority\": 20,\n \"source\": \"conversation\",\n \"subject\": \"中控Ai营销各Agent蓝图场景故事动线输出1-2页PPT\",\n \"taskId\": \"55119034912\",\n \"tenantId\": \"75985350515\",\n \"tenantType\": \"group\"\n }\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3658 - }, - { - "service": "todo", - "command": "+get-my-tasks", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +get-my-tasks --page 1 --size 10 --status false --priority x --role-types executor --plan-finish-start 1 --plan-finish-end 1 --yes --format json", - "args": [ - "todo", - "+get-my-tasks", - "--page", - "1", - "--size", - "10", - "--status", - "false", - "--priority", - "x", - "--role-types", - "executor", - "--plan-finish-start", - "1", - "--plan-finish-end", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"todos\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1475 - }, - { - "service": "todo", - "command": "+list-attachment", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +list-attachment --task-id 55119034912 --yes --format json", - "args": [ - "todo", - "+list-attachment", - "--task-id", - "55119034912", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"attachments\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3413 - }, - { - "service": "todo", - "command": "+list-comment", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +list-comment --task-id 55119034912 --page 1 --size 10 --yes --format json", - "args": [ - "todo", - "+list-comment", - "--task-id", - "55119034912", - "--page", - "1", - "--size", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"comments\": [],\n \"count\": 0\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2191 - }, - { - "service": "todo", - "command": "+list-sub", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +list-sub --task-id 55119034912 --yes --format json", - "args": [ - "todo", - "+list-sub", - "--task-id", - "55119034912", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"subTasks\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1940 - }, - { - "service": "todo", - "command": "+overdue", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +overdue --yes --format json", - "args": [ - "todo", - "+overdue", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"overdue\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1240 - }, - { - "service": "wiki", - "command": "+space-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test wiki +space-search --query '测试' --limit 10 --yes --format json", - "args": [ - "wiki", - "+space-search", - "--query", - "测试", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"spaces\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1951 - }, - { - "service": "aitable", - "command": "+base-get-primary-doc-id", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +base-get-primary-doc-id --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json", - "args": [ - "aitable", - "+base-get-primary-doc-id", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--record-id", - "1015oH3OXy", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"-1\\\",\\\"message\\\":\\\"no record\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to query cell doc for record 1015oH3OXy in table hERWDMS\\\",\\\"trace_id\\\":\\\"0b5deb3217840997466255627e08ee\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1929, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+chart-share-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +chart-share-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --chart-id widget-dlxFo0tNSImp4ITpDn5fQ --yes --format json", - "args": [ - "aitable", - "+chart-share-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--dashboard-id", - "KY9tlWg5NEHgfs8WT6IO2", - "--chart-id", - "widget-dlxFo0tNSImp4ITpDn5fQ", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"403\\\",\\\"message\\\":\\\"Forbidden\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"AUTH_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to get chart share config for chart widget-dlxFo0tNSImp4ITpDn5fQ: [403] Forbidden\\\",\\\"trace_id\\\":\\\"2127d89817840997481396653e075d\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1584, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "aitable", - "command": "+dashboard-share-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +dashboard-share-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --dashboard-id KY9tlWg5NEHgfs8WT6IO2 --yes --format json", - "args": [ - "aitable", - "+dashboard-share-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--dashboard-id", - "KY9tlWg5NEHgfs8WT6IO2", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"404\\\",\\\"message\\\":\\\"Not Found\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to get dashboard share config for dashboard KY9tlWg5NEHgfs8WT6IO2\\\",\\\"trace_id\\\":\\\"0bb7c36217840997497315836e07fe\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1472, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "aitable", - "command": "+export-data", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +export-data --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --task-id DWSREALREADNOSUCHID0000000000000 --scope all --format excel --table-id hERWDMS --view-id qvGDAH2 --timeout-ms 1 --yes", - "args": [ - "aitable", - "+export-data", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--task-id", - "DWSREALREADNOSUCHID0000000000000", - "--scope", - "all", - "--format", - "excel", - "--table-id", - "hERWDMS", - "--view-id", - "qvGDAH2", - "--timeout-ms", - "1", - "--yes" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_PARAMS\\\",\\\"message\\\":\\\"taskId cannot be combined with scope, format, tableId or viewId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to continue export because taskId mode cannot include create-task params\\\",\\\"trace_id\\\":\\\"2104a64c17840997514714448e0817\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1754, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+record-primary-doc-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +record-primary-doc-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json", - "args": [ - "aitable", - "+record-primary-doc-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--table-id", - "hERWDMS", - "--record-id", - "1015oH3OXy", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"-1\\\",\\\"message\\\":\\\"no record\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to query cell doc for record 1015oH3OXy in table hERWDMS\\\",\\\"trace_id\\\":\\\"2127d89817840997528393730e079c\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1344, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+role-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +role-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --role-id x --yes --format json", - "args": [ - "aitable", - "+role-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--role-id", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_PARAMS\\\",\\\"message\\\":\\\"roleId is required\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to get role because roleId is missing\\\",\\\"trace_id\\\":\\\"2104a64c17840997542904838e0817\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1450, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+workflow-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +workflow-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --workflow-id DWSREALREADNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+workflow-get", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--workflow-id", - "DWSREALREADNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"GET_WORKFLOW_ERROR\\\",\\\"message\\\":\\\"调用远程服务业务异常\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to get workflow in base 'gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk' due to an unexpected error\\\",\\\"trace_id\\\":\\\"2104a64c17840997556363676e08ee\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1535, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+workflow-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +workflow-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --limit 10 --offset 1 --yes --format json", - "args": [ - "aitable", - "+workflow-list", - "--base-id", - "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk", - "--limit", - "10", - "--offset", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"LIST_WORKFLOWS_ERROR\\\",\\\"message\\\":\\\"biz error\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to list workflows in base 'gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk' due to an unexpected error\\\",\\\"trace_id\\\":\\\"2127d89817840997572427879e075d\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1540, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "attendance", - "command": "+get-class", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +get-class --class-id 1 --yes --format json", - "args": [ - "attendance", - "+get-class", - "--class-id", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"rule not found\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2127d89817840997588238831e0757\"\n }\n}", - "exit_code": 1, - "duration_ms": 1492, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "attendance", - "command": "+get-global-setting", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +get-global-setting --setting-scene checkRemind --scope '企业' --yes --format json", - "args": [ - "attendance", - "+get-global-setting", - "--setting-scene", - "checkRemind", - "--scope", - "企业", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"NO_PERMISSION\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2104a64c17840997604718062e085e\"\n }\n}", - "exit_code": 1, - "duration_ms": 1635, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "attendance", - "command": "+get-group", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +get-group --group-id 1 --yes --format json", - "args": [ - "attendance", - "+get-group", - "--group-id", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"rule not found\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"0b5deb3217840997620512305e08ef\"\n }\n}", - "exit_code": 1, - "duration_ms": 1568, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "attendance", - "command": "+get-group-filtered", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +get-group-filtered --group-id 1 --member --position --wifi --bles --yes --format json", - "args": [ - "attendance", - "+get-group-filtered", - "--group-id", - "1", - "--member", - "--position", - "--wifi", - "--bles", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"rule not found\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2104a64c17840997638062468e08c7\"\n }\n}", - "exit_code": 1, - "duration_ms": 1746, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "attendance", - "command": "+get-leave-balance", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +get-leave-balance --users 202397 --leave-code 731ed089-62ff-4734-a6c7-3c8fcc8294fc --yes --format json", - "args": [ - "attendance", - "+get-leave-balance", - "--users", - "202397", - "--leave-code", - "731ed089-62ff-4734-a6c7-3c8fcc8294fc", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"2002\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2104a64c17840997652901307e081a\"\n }\n}", - "exit_code": 1, - "duration_ms": 1479, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+list-report-columns", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +list-report-columns --yes --format json", - "args": [ - "attendance", - "+list-report-columns", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"AUTH_ERROR\",\n \"server_key\": \"attendance-wukong\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"6001\\\",\\\"errorMsg\\\":\\\"无权限操作\\\"} \",\n \"trace_id\": \"2127d89817840997666188472e0756\"\n }\n}", - "exit_code": 1, - "duration_ms": 1351, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "attendance", - "command": "+query-report-leave", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +query-report-leave --users 202397 --leave-names '年假' --start '2026-07-15 10:00:00' --end '2026-07-15 16:00:00' --yes --format json", - "args": [ - "attendance", - "+query-report-leave", - "--users", - "202397", - "--leave-names", - "年假", - "--start", - "2026-07-15 10:00:00", - "--end", - "2026-07-15 16:00:00", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"AUTH_ERROR\",\n \"server_key\": \"attendance-wukong\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"6001\\\",\\\"errorMsg\\\":\\\"无权限操作\\\"} \",\n \"trace_id\": \"2104a64c17840997679973065e085f\"\n }\n}", - "exit_code": 1, - "duration_ms": 1373, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "calendar", - "command": "+find-room", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test calendar +find-room --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --yes --format json", - "args": [ - "calendar", - "+find-room", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"458019\",\n \"server_key\": \"calendar\",\n \"trace_id\": \"0bb7c36217840997694975303e0758\"\n }\n}", - "exit_code": 1, - "duration_ms": 1498, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "calendar", - "command": "+room-find", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test calendar +room-find --start 2026-07-16T10:00:00+08:00 --end 2026-07-16T11:00:00+08:00 --available --group-id x --room-name x --limit 10 --page 1 --yes --format json", - "args": [ - "calendar", - "+room-find", - "--start", - "2026-07-16T10:00:00+08:00", - "--end", - "2026-07-16T11:00:00+08:00", - "--available", - "--group-id", - "x", - "--room-name", - "x", - "--limit", - "10", - "--page", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"458019\",\n \"server_key\": \"calendar\",\n \"trace_id\": \"2104a64c17840997709913005e0819\"\n }\n}", - "exit_code": 1, - "duration_ms": 1504, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+category-list-conversations", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +category-list-conversations --category-id 1 --exclude-muted --yes --format json", - "args": [ - "chat", - "+category-list-conversations", - "--category-id", - "1", - "--exclude-muted", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] listConversationsByCategoryV2 error\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"0bb7c36217840997724375834e0758\"\n }\n}", - "exit_code": 1, - "duration_ms": 1445, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+chat-get-by-id", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-get-by-id --group-id 1 --yes --format json", - "args": [ - "chat", - "+chat-get-by-id", - "--group-id", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840997739165535e07bd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1462, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+chat-members-get", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-members-get --id DWSREALREADNOSUCHID0000000000000 --users '冬翔' --yes --format json", - "args": [ - "chat", - "+chat-members-get", - "--id", - "DWSREALREADNOSUCHID0000000000000", - "--users", - "冬翔", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840997754345760e07bd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1518, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+chat-messages", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-messages --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --limit 10 --direction older --yes --format json", - "args": [ - "chat", - "+chat-messages", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--time", - "2026-07-15 10:00:00", - "--limit", - "10", - "--direction", - "older", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openCid or cid is required\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"chat\",\n \"trace_id\": \"2104a64c17840997767792656e085e\"\n }\n}", - "exit_code": 1, - "duration_ms": 1328, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+messages-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-list --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json", - "args": [ - "chat", - "+messages-list", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--time", - "2026-07-15 10:00:00", - "--forward", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openCid or cid is required\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"chat\",\n \"trace_id\": \"2127d89817840997797873841e0757\"\n }\n}", - "exit_code": 1, - "duration_ms": 1480, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+messages-resource-url", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-resource-url --type mediaId --resource-id x --message-id DWSREALREADNOSUCHID0000000000000 --open-conversation-id cid3Jijzhe2aqs9ysOXjhi05g== --yes --format json", - "args": [ - "chat", - "+messages-resource-url", - "--type", - "mediaId", - "--resource-id", - "x", - "--message-id", - "DWSREALREADNOSUCHID0000000000000", - "--open-conversation-id", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"RESOURCE_NOT_FOUND\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840997855423145e07dd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1264, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+search-msg", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +search-msg --group cid3Jijzhe2aqs9ysOXjhi05g== --query '测试' --days 7 --yes --format json", - "args": [ - "chat", - "+search-msg", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--query", - "测试", - "--days", - "7", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"SearchRightsDenied\",\n \"server_key\": \"chat\",\n \"trace_id\": \"0b5deb3217840997866824643e0853\"\n }\n}", - "exit_code": 1, - "duration_ms": 1519, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+thread-replies", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +thread-replies --group cid3Jijzhe2aqs9ysOXjhi05g== --topic-id x --time '2026-07-15 10:00:00' --limit 10 --yes --format json", - "args": [ - "chat", - "+thread-replies", - "--group", - "cid3Jijzhe2aqs9ysOXjhi05g==", - "--topic-id", - "x", - "--time", - "2026-07-15 10:00:00", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] failed to decrypt openConvThreadId\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"chat\",\n \"trace_id\": \"0b5deb3217840997883504915e0853\"\n }\n}", - "exit_code": 1, - "duration_ms": 1582, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "contact", - "command": "+get-roster", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test contact +get-roster --staff-id x --fields x --yes --format json", - "args": [ - "contact", - "+get-roster", - "--staff-id", - "x", - "--fields", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"2001\",\n \"server_key\": \"hrmregister\",\n \"trace_id\": \"2106d98117840997920166915e087b\"\n }\n}", - "exit_code": 1, - "duration_ms": 1895, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "contact", - "command": "+list-roster-fields", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test contact +list-roster-fields --yes --format json", - "args": [ - "contact", - "+list-roster-fields", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"2001\",\n \"server_key\": \"hrmregister\",\n \"trace_id\": \"213ee25c17840997934295673e08e2\"\n }\n}", - "exit_code": 1, - "duration_ms": 1305, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+credentials-get", - "risk": "read", - "method": "held; sensitive credential read", - "status": "held", - "input": "", - "args": [], - "stdout": "", - "stderr": "该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。", - "exit_code": null, - "duration_ms": 0, - "failure_category": "held", - "fixability": "manual-approval", - "diagnosis": "高风险或无安全目标,需人工逐项授权后执行。" - }, - { - "service": "drive", - "command": "+download", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test drive +download --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --space-id 1 --yes --format json", - "args": [ - "drive", - "+download", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--space-id", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"invalidRequest.document.typeIllegal\",\n \"server_key\": \"drive\",\n \"trace_id\": \"0bab027317840997956923965e08c9\"\n }\n}", - "exit_code": 1, - "duration_ms": 1192, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "drive", - "command": "+list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test drive +list --space-id 1 --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --limit 10 --cursor 0 --order-by name --order asc --thumbnail --yes --format json", - "args": [ - "drive", - "+list", - "--space-id", - "1", - "--folder", - "Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ", - "--limit", - "10", - "--cursor", - "0", - "--order-by", - "name", - "--order", - "asc", - "--thumbnail", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"paramError\",\n \"server_key\": \"drive\",\n \"trace_id\": \"2106d98117840997968627612e087b\"\n }\n}", - "exit_code": 1, - "duration_ms": 1090, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "minutes", - "command": "+action-items", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test minutes +action-items --yes --format json", - "args": [ - "minutes", - "+action-items", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"暂无妙记\"\n }\n}", - "exit_code": 3, - "duration_ms": 1286, - "failure_category": "missing-real-minutes-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。" - }, - { - "service": "minutes", - "command": "+latest-minutes", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test minutes +latest-minutes --keyword '测试' --yes --format json", - "args": [ - "minutes", - "+latest-minutes", - "--keyword", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"暂无妙记\"\n }\n}", - "exit_code": 3, - "duration_ms": 8499, - "failure_category": "missing-real-minutes-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。" - }, - { - "service": "minutes", - "command": "+list-shared", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test minutes +list-shared --query '测试' --limit 10 --cursor 0 --yes --format json", - "args": [ - "minutes", - "+list-shared", - "--query", - "测试", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 0,\n \"minutes\": []\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1362 - }, - { - "service": "minutes", - "command": "+minutes-search", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test minutes +minutes-search --query '测试' --yes --format json", - "args": [ - "minutes", - "+minutes-search", - "--query", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"没搜到妙记\"\n }\n}", - "exit_code": 3, - "duration_ms": 1150, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "minutes", - "command": "+transcript", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test minutes +transcript --keyword '测试' --direction older --yes --format json", - "args": [ - "minutes", - "+transcript", - "--keyword", - "测试", - "--direction", - "older", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"暂无妙记\"\n }\n}", - "exit_code": 3, - "duration_ms": 1096, - "failure_category": "missing-real-minutes-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。" - }, - { - "service": "oa", - "command": "+done-approvals", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test oa +done-approvals --limit 10 --yes --format json", - "args": [ - "oa", - "+done-approvals", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"没有已处理的审批记录\"\n }\n}", - "exit_code": 3, - "duration_ms": 1244, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "oa", - "command": "+pending", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test oa +pending --limit 10 --yes --format json", - "args": [ - "oa", - "+pending", - "--limit", - "10", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"当前没有待我审批的任务\"\n }\n}", - "exit_code": 3, - "duration_ms": 1357, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "report", - "command": "+report-latest", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test report +report-latest --keyword '测试' --yes --format json", - "args": [ - "report", - "+report-latest", - "--keyword", - "测试", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"暂无日志\"\n }\n}", - "exit_code": 3, - "duration_ms": 1259, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "todo", - "command": "+due-today", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test todo +due-today --role-types executor --yes --format json", - "args": [ - "todo", - "+due-today", - "--role-types", - "executor", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"今天没有到期的待办\"\n }\n}", - "exit_code": 3, - "duration_ms": 1145, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "todo", - "command": "+related-tasks", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test todo +related-tasks --role-types executor --status false --yes --format json", - "args": [ - "todo", - "+related-tasks", - "--role-types", - "executor", - "--status", - "false", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"没有与你相关的待办(creator/executor/participant 三种角色下均为空)\"\n }\n}", - "exit_code": 3, - "duration_ms": 1122, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "wiki", - "command": "+node-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test wiki +node-list --workspace DWSREALREADNOSUCHID0000000000000 --folder DWSREALREADNOSUCHID0000000000000 --limit 10 --cursor 0 --yes --format json", - "args": [ - "wiki", - "+node-list", - "--workspace", - "DWSREALREADNOSUCHID0000000000000", - "--folder", - "DWSREALREADNOSUCHID0000000000000", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"wiki\",\n \"technical_detail\": \"PARAM_ERROR - 未找到指定工具\",\n \"trace_id\": \"2132f5ca17840998189126304e08d9\"\n }\n}", - "exit_code": 1, - "duration_ms": 1120, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "wiki", - "command": "+resolve-space", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test wiki +resolve-space --name 'DWS shortcut 真实测试' --yes --format json", - "args": [ - "wiki", - "+resolve-space", - "--name", - "DWS shortcut 真实测试", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"没有找到名称包含 DWS shortcut 真实测试 的知识空间\"\n }\n}", - "exit_code": 3, - "duration_ms": 1533, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "wiki", - "command": "+space-list", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test wiki +space-list --type ALL --limit 10 --cursor 0 --yes --format json", - "args": [ - "wiki", - "+space-list", - "--type", - "ALL", - "--limit", - "10", - "--cursor", - "0", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"参数 --type 取值 \\\"ALL\\\" 不合法,允许值:orgWikiSpace, myWikiSpace\"\n }\n}", - "exit_code": 3, - "duration_ms": 759, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+group-members", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +group-members --group '浅曦-kida,Dennis,秋画' --yes --format json", - "args": [ - "chat", - "+group-members", - "--group", - "浅曦-kida,Dennis,秋画", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 3,\n \"members\": [\n {\n \"name\": \"\",\n \"nick\": \"秋画\",\n \"openDingtalkId\": \"DpDC43OwfSyzvpshro641ZyKOpvhZI2tI\",\n \"role\": \"普通成员\"\n },\n {\n \"name\": \"\",\n \"nick\": \"浅曦\",\n \"openDingtalkId\": \"DiPbzolsu3XcIqiPOT4eDpd1MUBtiiFg6Xhx\",\n \"role\": \"群主\"\n },\n {\n \"name\": \"\",\n \"nick\": \"念晨\",\n \"openDingtalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"role\": \"普通成员\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1955 - }, - { - "service": "chat", - "command": "+messages-mget", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-mget --msg-ids msgEuOor1PmFBNlx9M06N9z1Q== --yes --format json", - "args": [ - "chat", - "+messages-mget", - "--msg-ids", - "msgEuOor1PmFBNlx9M06N9z1Q==", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"messages\": [\n {\n \"content\": \"DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。\",\n \"createTime\": \"2026-07-15 11:01:34\",\n \"openConversationId\": \"cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=\",\n \"openMessageId\": \"msgEuOor1PmFBNlx9M06N9z1Q==\",\n \"sender\": \"念晨\",\n \"senderOpenDingTalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\"\n }\n ]\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1461 - }, - { - "service": "chat", - "command": "+messages-query-send-status", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-query-send-status --open-task-id y/wM6Lo+9GbIqtILYPv1BZDcMW+2FgnqskgcpdOiMdM= --yes --format json", - "args": [ - "chat", - "+messages-query-send-status", - "--open-task-id", - "y/wM6Lo+9GbIqtILYPv1BZDcMW+2FgnqskgcpdOiMdM=", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"openConversationId\": \"cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=\",\n \"openMessageId\": \"msgEuOor1PmFBNlx9M06N9z1Q==\",\n \"sendStatus\": \"SUCCESS\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1312 - }, - { - "service": "chat", - "command": "+messages-read-status", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-read-status --conversation-id cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI= --message-id msgEuOor1PmFBNlx9M06N9z1Q== --users '冬翔' --yes --format json", - "args": [ - "chat", - "+messages-read-status", - "--conversation-id", - "cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=", - "--message-id", - "msgEuOor1PmFBNlx9M06N9z1Q==", - "--users", - "冬翔", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"memberReadStatusList\": [\n {\n \"openDingTalkId\": \"DAdiiQDqzH1DEqiPOT4eDpd1MUBtiiFg6Xhx\",\n \"readStatus\": \"READ\",\n \"readTime\": \"2026-07-15 11:53:31\",\n \"userNick\": \"冬翔\"\n },\n {\n \"openDingTalkId\": \"D1jIqGatuH3UykpdYxJGSS1ug4rSiPJt8I\",\n \"readStatus\": \"READ\",\n \"readTime\": \"2026-07-15 11:01:35\",\n \"userNick\": \"念晨\"\n }\n ],\n \"messageStatus\": \"SUCCESS\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1376 - }, - { - "service": "ding", - "command": "+receiver-status", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test ding +receiver-status --ding-id 5D73E3AC29C780072D1CD56C6874ACC2 --yes --format json", - "args": [ - "ding", - "+receiver-status", - "--ding-id", - "5D73E3AC29C780072D1CD56C6874ACC2", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"receivers\": [\n {\n \"confirmedStatus\": 1,\n \"openDingId\": \"5D73E3AC29C780072D1CD56C6874ACC2\",\n \"receiverNick\": \"粤腾\"\n }\n ]\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1823 - }, - { - "service": "sheet", - "command": "+list-sheets", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test sheet +list-sheets --node mweZ92PV6O36dZbnsMZx70ylJxEKBD6p --yes --format json", - "args": [ - "sheet", - "+list-sheets", - "--node", - "mweZ92PV6O36dZbnsMZx70ylJxEKBD6p", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"count\": 1,\n \"sheets\": [\n {\n \"sheetId\": \"kgqie6hm\",\n \"title\": \"Sheet1\"\n }\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1690 - }, - { - "service": "contact", - "command": "+by-mobile", - "risk": "read", - "method": "real-backend-read; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test contact +by-mobile --mobile 13161187007 --yes --format json", - "args": [ - "contact", - "+by-mobile", - "--mobile", - "13161187007", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"result\": [\n {\n \"isAdmin\": false,\n \"orgEmployeeModel\": {\n \"depts\": [\n {\n \"deptId\": 842379556,\n \"deptName\": \"模型算法\"\n }\n ],\n \"jobNumber\": \"202397\",\n \"labels\": [],\n \"orgAuthEmail\": null,\n \"orgId\": null,\n \"orgMasterDisplayName\": \"张俊韶\",\n \"orgMasterUserId\": \"030386\",\n \"orgName\": \"钉钉\",\n \"orgTitle\": \"\",\n \"orgUserId\": \"202397\",\n \"orgUserName\": \"董鑫阳\",\n \"positions\": [\n {\n \"deptId\": 842379556,\n \"isMain\": true,\n \"managerDisplayName\": null,\n \"managerStaffId\": null,\n \"title\": null,\n \"workStation\": \"浙江-杭州-阿里中心·杭州未科 D1-20-015\"\n }\n ]\n }\n }\n ],\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1884 - } - ] -} \ No newline at end of file diff --git a/docs/shortcut-real-write-results.json b/docs/shortcut-real-write-results.json deleted file mode 100644 index c56136fa..00000000 --- a/docs/shortcut-real-write-results.json +++ /dev/null @@ -1,4456 +0,0 @@ -{ - "generated_at": "2026-07-15T16:04:54.178709", - "summary": { - "total": 162, - "ok": 48, - "error": 114, - "timeout": 0, - "held": 0 - }, - "failure_categories": { - "missing-real-aitable-fixture": 31, - "backend-or-mcp-error": 24, - "missing-real-resource": 23, - "input-or-business-validation": 20, - "auth-or-permission": 16 - }, - "results": [ - { - "service": "todo", - "command": "+assign", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +assign --to '冬翔' --task 'DWS shortcut 真实测试 20260715 assign,可忽略' --due 2026-07-16T10:00:00+08:00 --yes --format json", - "args": [ - "todo", - "+assign", - "--to", - "冬翔", - "--task", - "DWS shortcut 真实测试 20260715 assign,可忽略", - "--due", - "2026-07-16T10:00:00+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"result\": {\n \"subject\": \"DWS shortcut 真实测试 20260715 assign,可忽略\",\n \"taskId\": \"55037683708\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1651, - "recorded_at": "2026-07-15T10:12:09.374178" - }, - { - "service": "todo", - "command": "+assign-multi", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +assign-multi --to '冬翔,克谨,怒龙' --task 'DWS shortcut 真实测试 20260715 assign-multi,可忽略' --yes --format json", - "args": [ - "todo", - "+assign-multi", - "--to", - "冬翔,克谨,怒龙", - "--task", - "DWS shortcut 真实测试 20260715 assign-multi,可忽略", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"result\": {\n \"subject\": \"DWS shortcut 真实测试 20260715 assign-multi,可忽略\",\n \"taskId\": \"55037747621\"\n },\n \"success\": true\n}\n{\n \"count\": 3,\n \"executors\": [\n \"夏东翔(103262)\",\n \"黄渊卓(519019)\",\n \"刘力(075771)\"\n ],\n \"subject\": \"DWS shortcut 真实测试 20260715 assign-multi,可忽略\"\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2445, - "recorded_at": "2026-07-15T10:12:11.927124" - }, - { - "service": "calendar", - "command": "+book", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +book --title 'DWS shortcut 真实测试 20260715 日程,可忽略' --start 2026-07-16T11:00:00+08:00 --end 2026-07-16T11:15:00+08:00 --with '冬翔,克谨,怒龙' --yes --format json", - "args": [ - "calendar", - "+book", - "--title", - "DWS shortcut 真实测试 20260715 日程,可忽略", - "--start", - "2026-07-16T11:00:00+08:00", - "--end", - "2026-07-16T11:15:00+08:00", - "--with", - "冬翔,克谨,怒龙", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"result\": {\n \"attendees\": [\n {\n \"displayName\": \"怒龙\",\n \"optional\": false,\n \"responseStatus\": \"needsAction\",\n \"self\": false\n },\n {\n \"displayName\": \"冬翔\",\n \"optional\": false,\n \"responseStatus\": \"needsAction\",\n \"self\": false\n },\n {\n \"displayName\": \"念晨\",\n \"optional\": false,\n \"responseStatus\": \"accepted\",\n \"self\": true\n },\n {\n \"displayName\": \"黄渊卓(克谨)\",\n \"optional\": false,\n \"responseStatus\": \"needsAction\",\n \"self\": false\n }\n ],\n \"categories\": [],\n \"createTime\": \"2026-07-15 10:12:30\",\n \"end\": {\n \"dateTime\": \"2026-07-16T11:15:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\"\n },\n \"extendedProperties\": {\n \"sharedProperties\": {\n \"belongCorpId\": \"ding8196cd9a2b2405da24f2f5cc6abecb85\"\n }\n },\n \"freeBusy\": \"BUSY\",\n \"iCalUID\": \"c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09\",\n \"id\": \"c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09\",\n \"isAllDay\": false,\n \"meetingRooms\": [],\n \"onlineMeetingInfo\": {\n \"conferenceId\": \"c76abd52-29bb-4183-85d3-bcf91debe3e4\",\n \"extraInfo\": {\n \"phones\": \"[\\\"+862381045152\\\",\\\"+862381045153\\\"]\",\n \"roomCode\": \"131 508 161\"\n },\n \"internalExtraInfo\": {\n \"urlForCalDAV\": \"dingtalk://dingtalkclient/page/videoConfFromSystemCalendar?confId=c76abd52-29bb-4183-85d3-bcf91debe3e4\\u0026calendarId=24714119393\"\n },\n \"type\": \"dingtalk\",\n \"url\": \"dingtalk://dingtalkclient/page/videoConfFromCalendar?confId=c76abd52-29bb-4183-85d3-bcf91debe3e4\\u0026calendarId=24714119393\"\n },\n \"organizer\": {\n \"displayName\": \"念晨\",\n \"optional\": false,\n \"self\": true\n },\n \"reminders\": [\n {\n \"method\": \"dingtalk\",\n \"minutes\": 15\n }\n ],\n \"start\": {\n \"dateTime\": \"2026-07-16T11:00:00+08:00\",\n \"timeZone\": \"Asia/Shanghai\"\n },\n \"status\": \"confirmed\",\n \"summary\": \"DWS shortcut 真实测试 20260715 日程,可忽略\",\n \"updateTime\": \"2026-07-15 10:12:31\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2909, - "recorded_at": "2026-07-15T10:12:31.399705" - }, - { - "service": "calendar", - "command": "+reschedule", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +reschedule --event c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09 --start 2026-07-16T11:30:00+08:00 --end 2026-07-16T11:45:00+08:00 --yes --format json", - "args": [ - "calendar", - "+reschedule", - "--event", - "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09", - "--start", - "2026-07-16T11:30:00+08:00", - "--end", - "2026-07-16T11:45:00+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1960, - "recorded_at": "2026-07-15T10:13:18.426070" - }, - { - "service": "calendar", - "command": "+invite", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +invite --event c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09 --with '冬翔' --yes --format json", - "args": [ - "calendar", - "+invite", - "--event", - "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09", - "--with", - "冬翔", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2033, - "recorded_at": "2026-07-15T10:13:23.127843" - }, - { - "service": "calendar", - "command": "+cancel-event", - "risk": "high-risk-write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test calendar +cancel-event --event c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09 --yes --format json", - "args": [ - "calendar", - "+cancel-event", - "--event", - "c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"action\": \"about_to_cancel\",\n \"eventId\": \"c0YxQWVyWDFiTjB6MHdmczVvNUZ5dz09\",\n \"title\": \"DWS shortcut 真实测试 20260715 日程,可忽略\"\n}\n{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1369, - "recorded_at": "2026-07-15T10:13:40.867196" - }, - { - "service": "chat", - "command": "+category-create", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +category-create --title 'DWS测0715' --yes --format json", - "args": [ - "chat", - "+category-create", - "--title", - "DWS测0715", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"categoryId\": 1784081668329\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 959, - "recorded_at": "2026-07-15T10:14:28.325931" - }, - { - "service": "chat", - "command": "+category-rename", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +category-rename --category-id 1784081668329 --title 'DWS改0715' --yes --format json", - "args": [ - "chat", - "+category-rename", - "--category-id", - "1784081668329", - "--title", - "DWS改0715", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1225, - "recorded_at": "2026-07-15T10:14:58.751623" - }, - { - "service": "chat", - "command": "+category-delete", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +category-delete --category-id 1784081668329 --yes --format json", - "args": [ - "chat", - "+category-delete", - "--category-id", - "1784081668329", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"success\": true\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1378, - "recorded_at": "2026-07-15T10:15:00.095333" - }, - { - "service": "chat", - "command": "+send-to-group", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +send-to-group --group 'DWS测0715' --text 'DWS shortcut 真实测试 20260715:验证 chat +send-to-group,请忽略。' --yes --format json", - "args": [ - "chat", - "+send-to-group", - "--group", - "DWS测0715", - "--text", - "DWS shortcut 真实测试 20260715:验证 chat +send-to-group,请忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"openTaskId\": \"tECwJpPL2sAnZwUZhjTFhNWleCgSp0GSMPlGkMUIL5Q=\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1854, - "recorded_at": "2026-07-15T10:16:22.214033" - }, - { - "service": "chat", - "command": "+chat-update-nick", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-update-nick --group cidXgIVDDzZE7kdQ+bcp3gWNw== --nick 'DWS测试' --yes --format json", - "args": [ - "chat", - "+chat-update-nick", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--nick", - "DWS测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1478, - "recorded_at": "2026-07-15T10:16:22.730031" - }, - { - "service": "chat", - "command": "+chat-update-alias", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-update-alias --group cidXgIVDDzZE7kdQ+bcp3gWNw== --alias-title 'DWS群备注' --yes --format json", - "args": [ - "chat", - "+chat-update-alias", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--alias-title", - "DWS群备注", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1298, - "recorded_at": "2026-07-15T10:16:23.398622" - }, - { - "service": "chat", - "command": "+chat-mute", - "risk": "write", - "method": "real-backend-manual-write cleanup/unmute; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-mute --group cidXgIVDDzZE7kdQ+bcp3gWNw== --off --yes --format json", - "args": [ - "chat", - "+chat-mute", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1252, - "recorded_at": "2026-07-15T10:16:43.375260" - }, - { - "service": "chat", - "command": "+chat-set-history", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-set-history --group cidXgIVDDzZE7kdQ+bcp3gWNw== --option RECENT_100 --yes --format json", - "args": [ - "chat", - "+chat-set-history", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--option", - "RECENT_100", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1219, - "recorded_at": "2026-07-15T10:17:31.670629" - }, - { - "service": "chat", - "command": "+chat-role-add", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-role-add --group cidXgIVDDzZE7kdQ+bcp3gWNw== --name 'DWS测试角色' --yes --format json", - "args": [ - "chat", - "+chat-role-add", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--name", - "DWS测试角色", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"openRoleId\": \"role9G67RdPFmhF1VxBpguhr1QiEiE\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1281, - "recorded_at": "2026-07-15T10:17:33.706668" - }, - { - "service": "chat", - "command": "+chat-set-admin", - "risk": "write", - "method": "real-backend-manual-write cleanup/unset-admin; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-set-admin --group cidXgIVDDzZE7kdQ+bcp3gWNw== --users 103262 --off --yes --format json", - "args": [ - "chat", - "+chat-set-admin", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--users", - "103262", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1554, - "recorded_at": "2026-07-15T10:17:54.361957" - }, - { - "service": "chat", - "command": "+chat-role-update", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-role-update --group cidXgIVDDzZE7kdQ+bcp3gWNw== --role-id role9G67RdPFmhF1VxBpguhr1QiEiE --name 'DWS角色改' --yes --format json", - "args": [ - "chat", - "+chat-role-update", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--role-id", - "role9G67RdPFmhF1VxBpguhr1QiEiE", - "--name", - "DWS角色改", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1366, - "recorded_at": "2026-07-15T10:18:16.042372" - }, - { - "service": "chat", - "command": "+chat-role-set-user", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-role-set-user --group cidXgIVDDzZE7kdQ+bcp3gWNw== --user 103262 --role-ids role9G67RdPFmhF1VxBpguhr1QiEiE --yes --format json", - "args": [ - "chat", - "+chat-role-set-user", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--user", - "103262", - "--role-ids", - "role9G67RdPFmhF1VxBpguhr1QiEiE", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1355, - "recorded_at": "2026-07-15T10:18:18.422186" - }, - { - "service": "doc", - "command": "+version-save", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +version-save --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --yes --format json", - "args": [ - "doc", - "+version-save", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"message\": \"文档版本已成功保存。\",\n \"savedVersion\": 1,\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1853, - "recorded_at": "2026-07-15T10:22:15.539141" - }, - { - "service": "doc", - "command": "+comment-create", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +comment-create --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --content 'DWS shortcut 真实测试 20260715:验证 doc +comment-create,可忽略。' --yes --format json", - "args": [ - "doc", - "+comment-create", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--content", - "DWS shortcut 真实测试 20260715:验证 doc +comment-create,可忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"commentKey\": \"1784082136009b316ba6c48ad41ad92a8b6905406a17c\",\n \"logId\": \"2132f5ca17840821358717098e08d7\",\n \"message\": \"评论创建成功。commentKey: 1784082136009b316ba6c48ad41ad92a8b6905406a17c\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2263, - "recorded_at": "2026-07-15T10:22:16.077531" - }, - { - "service": "doc", - "command": "+doc-append", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +doc-append --doc P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --text 'DWS shortcut 真实测试 20260715:验证 doc +doc-append。' --yes --format json", - "args": [ - "doc", - "+doc-append", - "--doc", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--text", - "DWS shortcut 真实测试 20260715:验证 doc +doc-append。", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"logId\": \"213ee25c17840821367055154e08a0\",\n \"message\": \"内容已成功追加到文档末尾,原有内容保持不变。\",\n \"mode\": \"append\",\n \"nodeId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2892, - "recorded_at": "2026-07-15T10:22:17.006167" - }, - { - "service": "doc", - "command": "+comment-reply", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +comment-reply --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --comment-key 1784082136009b316ba6c48ad41ad92a8b6905406a17c --content 'DWS shortcut 真实测试 20260715:验证 doc +comment-reply,可忽略。' --yes --format json", - "args": [ - "doc", - "+comment-reply", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--comment-key", - "1784082136009b316ba6c48ad41ad92a8b6905406a17c", - "--content", - "DWS shortcut 真实测试 20260715:验证 doc +comment-reply,可忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"commentKey\": \"1784082170012979f5c547f7a49e88a6c1dd718bbb304\",\n \"logId\": \"2106d98117840821697518379e089c\",\n \"message\": \"回复评论成功。commentKey: 1784082170012979f5c547f7a49e88a6c1dd718bbb304\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1553, - "recorded_at": "2026-07-15T10:22:50.048965" - }, - { - "service": "doc", - "command": "+copy", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +copy --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json", - "args": [ - "doc", - "+copy", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--folder", - "Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"docUrl\": \"https://alidocs.dingtalk.com/i/nodes/7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3\",\n \"message\": \"节点复制成功,已复制到目标文件夹。sourceNodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ,新节点 ID: 7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3\",\n \"nodeId\": \"7dx2rn0Jbakrq9O5tZvQ1bG9VMGjLRb3\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1531, - "recorded_at": "2026-07-15T10:23:13.821964" - }, - { - "service": "doc", - "command": "+move", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +move --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json", - "args": [ - "doc", - "+move", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--folder", - "Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"docUrl\": \"https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"message\": \"节点移动成功,已移动到目标文件夹。nodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ\",\n \"nodeId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1087, - "recorded_at": "2026-07-15T10:23:15.362080" - }, - { - "service": "doc", - "command": "+version-revert", - "risk": "high-risk-write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +version-revert --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --version 1 --yes --format json", - "args": [ - "doc", - "+version-revert", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--version", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"message\": \"文档已成功回滚到指定版本。\",\n \"revertedToVersion\": 1,\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2065, - "recorded_at": "2026-07-15T10:23:20.422266" - }, - { - "service": "chat", - "command": "+chat-dismiss", - "risk": "high-risk-write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +chat-dismiss --group cidXgIVDDzZE7kdQ+bcp3gWNw== --yes --format json", - "args": [ - "chat", - "+chat-dismiss", - "--group", - "cidXgIVDDzZE7kdQ+bcp3gWNw==", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1120, - "recorded_at": "2026-07-15T10:30:27.746562" - }, - { - "service": "devapp", - "command": "+create", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +create --name 'DWS测试应用0715' --desc 'DWS shortcut 真实测试,可删除' --yes --format json", - "args": [ - "devapp", - "+create", - "--name", - "DWS测试应用0715", - "--desc", - "DWS shortcut 真实测试,可删除", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"appKey\": \"dingyqsdchocxp3xrfm3\",\n \"desc\": \"DWS shortcut 真实测试,可删除\",\n \"icon\": \"@lADPDfmVQafoVxrMyMzI\",\n \"iconUrl\": \"https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg\",\n \"name\": \"DWS测试应用0715\",\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1464, - "recorded_at": "2026-07-15T10:31:40.493231" - }, - { - "service": "devapp", - "command": "+update", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +update --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --name 'DWS测试应用0715改' --desc 'DWS shortcut 真实测试已更新' --yes --format json", - "args": [ - "devapp", - "+update", - "--unified-app-id", - "c5a8e611-1555-44a0-ba8d-9617ab599692", - "--name", - "DWS测试应用0715改", - "--desc", - "DWS shortcut 真实测试已更新", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"appKey\": \"dingyqsdchocxp3xrfm3\",\n \"desc\": \"DWS shortcut 真实测试已更新\",\n \"icon\": \"@lADPDfmVQafoVxrMyMzI\",\n \"iconUrl\": \"https://i01.lw.aliimg.com/media/lADPDfmVQafoVxrMyMzI_200_200.jpg\",\n \"message\": \"应用基础信息更新成功。\",\n \"name\": \"DWS测试应用0715改\",\n \"success\": true,\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\",\n \"updatedFields\": [\n \"name\",\n \"desc\"\n ],\n \"versionStatus\": \"INIT\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 5611, - "recorded_at": "2026-07-15T10:32:10.163399" - }, - { - "service": "devapp", - "command": "+disable", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +disable --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --yes --format json", - "args": [ - "devapp", - "+disable", - "--unified-app-id", - "c5a8e611-1555-44a0-ba8d-9617ab599692", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"message\": \"应用已停用。\",\n \"success\": true,\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2583, - "recorded_at": "2026-07-15T10:32:10.606118" - }, - { - "service": "devapp", - "command": "+member-add", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +member-add --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --user-ids 103262 --member-type DEVELOPER --yes --format json", - "args": [ - "devapp", - "+member-add", - "--unified-app-id", - "c5a8e611-1555-44a0-ba8d-9617ab599692", - "--user-ids", - "103262", - "--member-type", - "DEVELOPER", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"memberType\": \"DEVELOPER\",\n \"operation\": \"ADDED\",\n \"success\": true,\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\",\n \"userIds\": [\n \"103262\"\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2393, - "recorded_at": "2026-07-15T10:32:10.630577" - }, - { - "service": "devapp", - "command": "+enable", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +enable --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --yes --format json", - "args": [ - "devapp", - "+enable", - "--unified-app-id", - "c5a8e611-1555-44a0-ba8d-9617ab599692", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"message\": \"应用已启用。\",\n \"success\": true,\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1506, - "recorded_at": "2026-07-15T10:32:41.282567" - }, - { - "service": "devapp", - "command": "+member-remove", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +member-remove --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --user-ids 103262 --member-type DEVELOPER --yes --format json", - "args": [ - "devapp", - "+member-remove", - "--unified-app-id", - "c5a8e611-1555-44a0-ba8d-9617ab599692", - "--user-ids", - "103262", - "--member-type", - "DEVELOPER", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"memberType\": \"DEVELOPER\",\n \"operation\": \"REMOVED\",\n \"success\": true,\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\",\n \"userIds\": [\n \"103262\"\n ]\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1188, - "recorded_at": "2026-07-15T10:32:45.215035" - }, - { - "service": "devapp", - "command": "+webapp-config", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +webapp-config --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --h5-page-type pc --homepage-url https://example.invalid/dws --pc-homepage-url https://example.invalid/dws-pc --omp-url https://example.invalid/omp --yes --format json", - "args": [ - "devapp", - "+webapp-config", - "--unified-app-id", - "c5a8e611-1555-44a0-ba8d-9617ab599692", - "--h5-page-type", - "pc", - "--homepage-url", - "https://example.invalid/dws", - "--pc-homepage-url", - "https://example.invalid/dws-pc", - "--omp-url", - "https://example.invalid/omp", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"configured\": true,\n \"h5PageType\": \"pc\",\n \"homepageUrl\": \"https://example.invalid/dws\",\n \"message\": \"网页应用配置成功。\",\n \"ompUrl\": \"https://example.invalid/omp\",\n \"pcHomepageUrl\": \"https://example.invalid/dws-pc\",\n \"success\": true,\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 4926, - "recorded_at": "2026-07-15T10:33:17.930444" - }, - { - "service": "devapp", - "command": "+delete", - "risk": "high-risk-write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test devapp +delete --unified-app-id c5a8e611-1555-44a0-ba8d-9617ab599692 --yes --format json", - "args": [ - "devapp", - "+delete", - "--unified-app-id", - "c5a8e611-1555-44a0-ba8d-9617ab599692", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"deleted\": true,\n \"message\": \"应用删除成功。\",\n \"success\": true,\n \"unifiedAppId\": \"c5a8e611-1555-44a0-ba8d-9617ab599692\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2868, - "recorded_at": "2026-07-15T10:33:37.241949" - }, - { - "service": "drive", - "command": "+move", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test drive +move --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json", - "args": [ - "drive", - "+move", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--folder", - "Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"docUrl\": \"https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"message\": \"节点移动成功,已移动到目标文件夹。nodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ\",\n \"nodeId\": \"P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 3380, - "recorded_at": "2026-07-15T10:34:07.475044" - }, - { - "service": "drive", - "command": "+copy", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test drive +copy --node P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --folder Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ --yes --format json", - "args": [ - "drive", - "+copy", - "--node", - "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--folder", - "Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"docUrl\": \"https://alidocs.dingtalk.com/i/nodes/np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l\",\n \"message\": \"节点复制成功,已复制到目标文件夹。sourceNodeId: P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO,targetFolderId: Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ,新节点 ID: np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l\",\n \"nodeId\": \"np9zOoBVBy2AdzEjuP40bzg4W1DK0g6l\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1329, - "recorded_at": "2026-07-15T10:34:08.215467" - }, - { - "service": "ding", - "command": "+send-personal", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test ding +send-personal --users 103262 --content 'DWS shortcut 真实测试 20260715:验证 ding +send-personal,请忽略。' --type app --uuid dws-shortcut-ding-20260715-001 --yes --format json", - "args": [ - "ding", - "+send-personal", - "--users", - "103262", - "--content", - "DWS shortcut 真实测试 20260715:验证 ding +send-personal,请忽略。", - "--type", - "app", - "--uuid", - "dws-shortcut-ding-20260715-001", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"openDingId\": \"8508193BEA49427AA9FFE94EB9D6B28E\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1338, - "recorded_at": "2026-07-15T10:34:35.058320" - }, - { - "service": "ding", - "command": "+recall-personal", - "risk": "high-risk-write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test ding +recall-personal --id 8508193BEA49427AA9FFE94EB9D6B28E --yes --format json", - "args": [ - "ding", - "+recall-personal", - "--id", - "8508193BEA49427AA9FFE94EB9D6B28E", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1415, - "recorded_at": "2026-07-15T10:34:51.767409" - }, - { - "service": "minutes", - "command": "+record-start", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test minutes +record-start --session-id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__ --yes --format json", - "args": [ - "minutes", - "+record-start", - "--session-id", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"cmd\": \"create\",\n \"operatorUid\": 512653609,\n \"sessionId\": \"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__\",\n \"uid\": 512653609\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 821 - }, - { - "service": "minutes", - "command": "+replace-batch", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test minutes +replace-batch --id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__ --pair '测试=>测试' --yes --format json", - "args": [ - "minutes", - "+replace-batch", - "--id", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__", - "--pair", - "测试=>测试", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"applied\": 0,\n \"failed\": 1,\n \"results\": [\n {\n \"error\": \"[UNCLASSIFIED] business error: success=false\\n hint: Use --verbose for detailed error logs\",\n \"originalText\": \"测试\",\n \"replacedText\": \"测试\"\n }\n ],\n \"taskUuid\": \"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__\",\n \"total\": 1\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 941 - }, - { - "service": "chat", - "command": "+messages-send-by-webhook", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-send-by-webhook --token __DWS_SHORTCUT_REAL_TEST_NO_SUCH_WEBHOOK_TOKEN__ --title 'DWS shortcut 真实测试 20260715-104025' --text 'DWS shortcut 真实测试 20260715-104025,安全负向输入,请忽略。' --at-all --at-mobiles x --at-users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json", - "args": [ - "chat", - "+messages-send-by-webhook", - "--token", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_WEBHOOK_TOKEN__", - "--title", - "DWS shortcut 真实测试 20260715-104025", - "--text", - "DWS shortcut 真实测试 20260715-104025,安全负向输入,请忽略。", - "--at-all", - "--at-mobiles", - "x", - "--at-users", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"errcode\": \"300005\",\n \"errmsg\": \"token is not exist\",\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 925 - }, - { - "service": "chat", - "command": "+messages-update-card", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +messages-update-card --biz-id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__ --content 'DWS shortcut 真实测试 20260715-104025,安全负向输入,请忽略。' --flow-status 1 --yes --format json", - "args": [ - "chat", - "+messages-update-card", - "--biz-id", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ID__", - "--content", - "DWS shortcut 真实测试 20260715-104025,安全负向输入,请忽略。", - "--flow-status", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 934 - }, - { - "service": "chat", - "command": "+conversation-clear-all-red-point", - "risk": "high-risk-write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +conversation-clear-all-red-point --yes --format json", - "args": [ - "chat", - "+conversation-clear-all-red-point", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {},\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 13459, - "recorded_at": "2026-07-15T10:45:08.066989" - }, - { - "service": "chat", - "command": "+dm", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +dm --to '冬翔' --text 'DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。' --yes --format json", - "args": [ - "chat", - "+dm", - "--to", - "冬翔", - "--text", - "DWS shortcut 修复复测 20260715:验证 chat +dm,请忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"openTaskId\": \"y/wM6Lo+9GbIqtILYPv1BZDcMW+2FgnqskgcpdOiMdM=\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2547, - "recorded_at": "2026-07-15T11:01:34.725515" - }, - { - "service": "chat", - "command": "+broadcast", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test chat +broadcast --to '冬翔,克谨,怒龙' --text 'DWS shortcut 修复复测 20260715:验证 chat +broadcast,请忽略。' --yes --format json", - "args": [ - "chat", - "+broadcast", - "--to", - "冬翔,克谨,怒龙", - "--text", - "DWS shortcut 修复复测 20260715:验证 chat +broadcast,请忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"failedCount\": 0,\n \"sent\": [\n \"夏东翔\",\n \"黄渊卓\",\n \"刘力\"\n ],\n \"sentCount\": 3\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2388, - "recorded_at": "2026-07-15T11:01:50.250374" - }, - { - "service": "doc", - "command": "+share-doc", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test doc +share-doc --to '冬翔' --url https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO --note 'DWS shortcut 修复复测 20260715:验证 doc +share-doc,请忽略。' --yes --format json", - "args": [ - "doc", - "+share-doc", - "--to", - "冬翔", - "--url", - "https://alidocs.dingtalk.com/i/nodes/P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO", - "--note", - "DWS shortcut 修复复测 20260715:验证 doc +share-doc,请忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"errorCode\": null,\n \"errorMsg\": null,\n \"result\": {\n \"openTaskId\": \"30ETViSW+9qhs7fBO9cbeSocHjj2iUZL4firJPz/h/I=\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1943, - "recorded_at": "2026-07-15T11:02:04.791099" - }, - { - "service": "todo", - "command": "+remind", - "risk": "write", - "method": "real-backend-manual-write; no --mock; no --dry-run; cleanup: taskId 55042039022 deleted successfully", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +remind --task 'DWS shortcut 修复复测 20260715 remind,可忽略' --at 2026-07-16T10:00:00+08:00 --yes --format json", - "args": [ - "todo", - "+remind", - "--task", - "DWS shortcut 修复复测 20260715 remind,可忽略", - "--at", - "2026-07-16T10:00:00+08:00", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"arguments\": [],\n \"result\": {\n \"subject\": \"DWS shortcut 修复复测 20260715 remind,可忽略\",\n \"taskId\": \"55042039022\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 4240, - "recorded_at": "2026-07-15T11:02:27.012678", - "cleanup": { - "input": "/private/tmp/dws-real-test todo task delete --task-id 55042039022 --yes --format json", - "stdout": "{\n \"result\": {\n \"success\": true\n },\n \"success\": true\n}", - "exit_code": 0, - "note": "真实复测创建的临时待办已删除" - } - }, - { - "service": "aitable", - "command": "+advperm-disable", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +advperm-disable --base-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+advperm-disable", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to set advanced permission for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998455921653e08de\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1447, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "aitable", - "command": "+advperm-enable", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +advperm-enable --base-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+advperm-enable", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to set advanced permission for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"0bab027317840998469916877e08c8\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1358, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "aitable", - "command": "+attachment-upload", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +attachment-upload --base-id DWSREALTESTNOSUCHID0000000000000 --file-name dws-shortcut-real-20260715-151724.txt --size 1 --mime-type text/plain --yes --format json", - "args": [ - "aitable", - "+attachment-upload", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--file-name", - "dws-shortcut-real-20260715-151724.txt", - "--size", - "1", - "--mime-type", - "text/plain", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"BASE_NOT_FOUND\\\",\\\"message\\\":\\\"Specified base does not exist, has been deleted, or is inaccessible\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to prepare attachment upload: base does not exist or is inaccessible\\\",\\\"trace_id\\\":\\\"213ee25c17840998481228948e087f\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1131, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+base-copy", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +base-copy --base-id DWSREALTESTNOSUCHID0000000000000 --target-folder-id x --only-struct --yes --format json", - "args": [ - "aitable", - "+base-copy", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--target-folder-id", - "x", - "--only-struct", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":null,\\\"message\\\":\\\"Invalid source baseId: DWSREALTESTNOSUCHID0000000000000\\\",\\\"retryable\\\":null,\\\"type\\\":\\\"USER_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Invalid source baseId\\\",\\\"trace_id\\\":\\\"213ee25c17840998493715617e08e2\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1318, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+base-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +base-delete --base-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json", - "args": [ - "aitable", - "+base-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--reason", - "DWS shortcut 真实测试安全负向清理", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"52600003\\\",\\\"message\\\":\\\"Data not found\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete base DWSREALTESTNOSUCHID0000000000000. Check whether the base still exists, the caller still has access, and the baseId is from get_base/search_bases before retrying.\\\",\\\"trace_id\\\":\\\"2132f5ca17840998505858640e0874\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1124, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "aitable", - "command": "+base-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +base-update --base-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --desc 'DWS shortcut 真实测试描述,可删除' --yes --format json", - "args": [ - "aitable", - "+base-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--desc", - "DWS shortcut 真实测试描述,可删除", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"BASE_NOT_FOUND\\\",\\\"message\\\":\\\"Specified base does not exist, has been deleted, or is inaccessible\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update base because base does not exist or is inaccessible\\\",\\\"trace_id\\\":\\\"2132f5ca17840998518767946e08b6\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1318, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+chart-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +chart-delete --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json", - "args": [ - "aitable", - "+chart-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dashboard-id", - "DWSREALTESTNOSUCHID0000000000000", - "--chart-id", - "DWSREALTESTNOSUCHID0000000000000", - "--reason", - "DWS shortcut 真实测试安全负向清理", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete chart DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"0bab027317840998530143696e08c9\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1154, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+chart-share-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +chart-share-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --enabled --share-type PUBLIC --allow-back-to-doc --yes --format json", - "args": [ - "aitable", - "+chart-share-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dashboard-id", - "DWSREALTESTNOSUCHID0000000000000", - "--chart-id", - "DWSREALTESTNOSUCHID0000000000000", - "--enabled", - "--share-type", - "PUBLIC", - "--allow-back-to-doc", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update chart share for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"0bab027317840998541842968e094d\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1175, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+chart-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +chart-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --layout '{}' --yes --format json", - "args": [ - "aitable", - "+chart-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dashboard-id", - "DWSREALTESTNOSUCHID0000000000000", - "--chart-id", - "DWSREALTESTNOSUCHID0000000000000", - "--config", - "{}", - "--layout", - "{}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_PARAMS\\\",\\\"message\\\":\\\"config is required\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update chart because config is missing\\\",\\\"trace_id\\\":\\\"2106d98117840998553244877e08df\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1102, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+dashboard-arrange", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +dashboard-arrange --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+dashboard-arrange", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dashboard-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to align dashboard for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2132f5ca17840998564478222e08d8\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1141, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+dashboard-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +dashboard-delete --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json", - "args": [ - "aitable", - "+dashboard-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dashboard-id", - "DWSREALTESTNOSUCHID0000000000000", - "--reason", - "DWS shortcut 真实测试安全负向清理", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete dashboard DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998577042546e0941\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1247, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+dashboard-share-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +dashboard-share-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --enabled --share-type PUBLIC --allow-back-to-doc --yes --format json", - "args": [ - "aitable", - "+dashboard-share-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dashboard-id", - "DWSREALTESTNOSUCHID0000000000000", - "--enabled", - "--share-type", - "PUBLIC", - "--allow-back-to-doc", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update dashboard share for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2132f5ca17840998588081246e0853\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1115, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+dashboard-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +dashboard-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --name 'DWS shortcut 真实测试 20260715-151724' --yes --format json", - "args": [ - "aitable", - "+dashboard-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dashboard-id", - "DWSREALTESTNOSUCHID0000000000000", - "--config", - "{}", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update dashboard DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998599403187e08b8\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1157, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+field-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +field-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+field-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--field-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"404\\\",\\\"message\\\":\\\"getDentryDTO returns null\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to get current field info before delete\\\",\\\"trace_id\\\":\\\"0bab027317840998611338768e08c8\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1157, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+field-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --config '{}' --ai-config '{}' --yes --format json", - "args": [ - "aitable", - "+field-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--field-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--config", - "{}", - "--ai-config", - "{}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"404\\\",\\\"message\\\":\\\"getDentryDTO returns null\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to get current field info for update\\\",\\\"trace_id\\\":\\\"213ee25c17840998623207342e08e2\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1196, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+form-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +form-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+form-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete form view for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"213ee25c17840998636217552e08e2\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1290, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+form-field-hide", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +form-field-hide --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --hidden --yes --format json", - "args": [ - "aitable", - "+form-field-hide", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--field-id", - "DWSREALTESTNOSUCHID0000000000000", - "--hidden", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update form field hidden for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998649456212e08df\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1312, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+form-field-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +form-field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --required --field-description 'DWS shortcut 真实测试字段说明' --yes --format json", - "args": [ - "aitable", - "+form-field-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--field-id", - "DWSREALTESTNOSUCHID0000000000000", - "--required", - "--field-description", - "DWS shortcut 真实测试字段说明", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update form field config for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"213ee25c17840998660847932e08e2\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1153, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+form-share-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +form-share-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --enabled true --yes --format json", - "args": [ - "aitable", - "+form-share-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--enabled", - "true", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update share form for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"213ee25c17840998675755538e087d\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1573, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+form-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +form-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --title 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --yes --format json", - "args": [ - "aitable", - "+form-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--title", - "DWS shortcut 真实测试 20260715-151724", - "--description", - "DWS shortcut 真实测试描述,可删除", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update form info for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2132f5ca17840998688302645e0853\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1141, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+import-data", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +import-data --import-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --timeout 1 --header-row 1 --src-sheet-name x --field-mapping '{}' --yes --format json", - "args": [ - "aitable", - "+import-data", - "--import-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--timeout", - "1", - "--header-row", - "1", - "--src-sheet-name", - "x", - "--field-mapping", - "{}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_IMPORT_ID\\\",\\\"message\\\":\\\"importId not found: either invalid or expired\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"importId「DWSREALTESTNOSUCHID0000000000000」无效或已过期(有效期 1 小时)。请勿重试相同 importId。需重新上传:① 调用 prepare_import_upload 获取新的 uploadUrl 和 importId;② 通过 HTTP PUT 将原始文件字节流上传至 uploadUrl;除非 uploadUrl 明确要求,否则不要额外附带 Content-Type 等自定义请求头;③ 调用 import_data 传入新的 importId。\\\",\\\"trace_id\\\":\\\"213ee25c17840998700326443e0944\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1227, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "aitable", - "command": "+import-upload", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +import-upload --base-id DWSREALTESTNOSUCHID0000000000000 --file-name dws-shortcut-real-20260715-151724.txt --file-size 1 --yes --format json", - "args": [ - "aitable", - "+import-upload", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--file-name", - "dws-shortcut-real-20260715-151724.txt", - "--file-size", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"无法解析 baseId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"无效的 baseId\\\",\\\"trace_id\\\":\\\"0bab027317840998713075490e096e\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1302, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+record-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +record-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --record-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+record-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--record-ids", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"404\\\",\\\"message\\\":\\\"getDentryDTO returns null\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete records\\\",\\\"trace_id\\\":\\\"2132f5ca17840998724753149e0853\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1159, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+record-primary-doc-create", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +record-primary-doc-create --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --record-id 999999999999 --yes --format json", - "args": [ - "aitable", - "+record-primary-doc-create", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--field-id", - "DWSREALTESTNOSUCHID0000000000000", - "--record-id", - "999999999999", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"RESOLVE_DOC_ID_ERROR\\\",\\\"message\\\":\\\"Failed to resolve docId from baseId\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to create cell doc because baseId cannot be resolved to docId\\\",\\\"trace_id\\\":\\\"213ee25c17840998736256985e0944\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1150, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+record-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +record-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[{\"recordId\":\"recDWSREALTEST\",\"cells\":{}}]' --yes --format json", - "args": [ - "aitable", - "+record-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--records", - "[{\"recordId\":\"recDWSREALTEST\",\"cells\":{}}]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_RECORDS\\\",\\\"message\\\":\\\"records must contain at least one writable record\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update records because no valid writable records remain for table DWSREALTESTNOSUCHID0000000000000. Skipped records with empty cells (1): recDWSREALTEST\\\",\\\"trace_id\\\":\\\"0bab027317840998747383236e090b\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1144, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+record-upsert", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +record-upsert --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[]' --yes --format json", - "args": [ - "aitable", - "+record-upsert", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--records", - "[]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_PARAMETER\\\",\\\"message\\\":\\\"records is required and must not be empty\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"records is required and must not be empty\\\",\\\"trace_id\\\":\\\"2106d98117840998759832182e087b\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1173, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+role-create", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +role-create --base-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json", - "args": [ - "aitable", - "+role-create", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--role-type", - "x", - "--flow-type", - "x", - "--sub-roles", - "[]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to create role for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"0bab027317840998770933580e090b\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1130, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+role-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +role-delete --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+role-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--role-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_PARAMS\\\",\\\"message\\\":\\\"roleId is required\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete role because roleId is missing\\\",\\\"trace_id\\\":\\\"2106d98117840998782482701e089c\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1150, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+role-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +role-update --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json", - "args": [ - "aitable", - "+role-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--role-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--role-type", - "x", - "--flow-type", - "x", - "--sub-roles", - "[]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_PARAMS\\\",\\\"message\\\":\\\"roleId is required\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to patch role because roleId is missing\\\",\\\"trace_id\\\":\\\"2132f5ca17840998794483634e08d8\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1209, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+section-create", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +section-create --base-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --parent-section-id DWSREALTESTNOSUCHID0000000000000 --index 1 --yes --format json", - "args": [ - "aitable", - "+section-create", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--parent-section-id", - "DWSREALTESTNOSUCHID0000000000000", - "--index", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to create section for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2132f5ca17840998805556786e08d9\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1117, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+section-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +section-delete --base-id DWSREALTESTNOSUCHID0000000000000 --section-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+section-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--section-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete section for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998817023336e087a\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1111, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+section-move-node", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +section-move-node --base-id DWSREALTESTNOSUCHID0000000000000 --node-id DWSREALTESTNOSUCHID0000000000000 --new-parent-section-id DWSREALTESTNOSUCHID0000000000000 --target-index 1 --yes --format json", - "args": [ - "aitable", - "+section-move-node", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--node-id", - "DWSREALTESTNOSUCHID0000000000000", - "--new-parent-section-id", - "DWSREALTESTNOSUCHID0000000000000", - "--target-index", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to move node for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998828667800e0920\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1186, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+section-rename", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +section-rename --base-id DWSREALTESTNOSUCHID0000000000000 --section-id DWSREALTESTNOSUCHID0000000000000 --new-name 'DWS shortcut 真实测试 20260715-151724' --yes --format json", - "args": [ - "aitable", - "+section-rename", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--section-id", - "DWSREALTESTNOSUCHID0000000000000", - "--new-name", - "DWS shortcut 真实测试 20260715-151724", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to rename section for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"213ee25c17840998840692547e08e2\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1259, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+section-reorder", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +section-reorder --base-id DWSREALTESTNOSUCHID0000000000000 --section-id DWSREALTESTNOSUCHID0000000000000 --target-index 1 --yes --format json", - "args": [ - "aitable", - "+section-reorder", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--section-id", - "DWSREALTESTNOSUCHID0000000000000", - "--target-index", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to reorder section for base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2132f5ca17840998853157434e08d9\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1193, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+table-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +table-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --reason 'DWS shortcut 真实测试安全负向清理' --yes --format json", - "args": [ - "aitable", - "+table-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--reason", - "DWS shortcut 真实测试安全负向清理", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"BASE_NOT_FOUND\\\",\\\"message\\\":\\\"Specified base does not exist, has been deleted, or is inaccessible\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete table because base does not exist or is inaccessible\\\",\\\"trace_id\\\":\\\"2132f5ca17840998865048372e0852\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1199, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+table-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +table-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --record-name-key task --yes --format json", - "args": [ - "aitable", - "+table-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--description", - "DWS shortcut 真实测试描述,可删除", - "--record-name-key", - "task", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"404\\\",\\\"message\\\":\\\"getDentryDTO returns null\\\",\\\"retryable\\\":true,\\\"type\\\":\\\"SYSTEM_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update table DWSREALTESTNOSUCHID0000000000000 in base DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"213ee25c17840998877246229e087f\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1249, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+view-delete", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +view-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+view-delete", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to delete view DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998889051250e08df\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1236, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+view-duplicate", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +view-duplicate --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --new-name 'DWS shortcut 真实测试 20260715-151724' --yes --format json", - "args": [ - "aitable", - "+view-duplicate", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--new-name", - "DWS shortcut 真实测试 20260715-151724", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to duplicate view DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2132f5ca17840998901765467e0853\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1160, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+view-lock", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +view-lock --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --off --yes --format json", - "args": [ - "aitable", - "+view-lock", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to lock_or_unlock view DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998913717245e0941\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1215, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+view-set-fill-color-rule", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +view-set-fill-color-rule --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --json '{}' --yes --format json", - "args": [ - "aitable", - "+view-set-fill-color-rule", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--json", - "{}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_PARAMS\\\",\\\"message\\\":\\\"conditionalFormats is required\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to set fill color rule because conditionalFormats is missing\\\",\\\"trace_id\\\":\\\"2106d98117840998924181709e08df\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1046, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。" - }, - { - "service": "aitable", - "command": "+view-set-frozen-cols", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +view-set-frozen-cols --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --count 1 --yes --format json", - "args": [ - "aitable", - "+view-set-frozen-cols", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--count", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to set frozen columns for view DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998935988648e08de\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1149, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+view-set-row-height", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +view-set-row-height --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --cell-height 1 --yes --format json", - "args": [ - "aitable", - "+view-set-row-height", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--cell-height", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to set cell height for view DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"2106d98117840998949428245e08b8\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1345, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+view-update", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +view-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --desc '{}' --config '{}' --yes --format json", - "args": [ - "aitable", - "+view-update", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--table-id", - "DWSREALTESTNOSUCHID0000000000000", - "--view-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--desc", - "{}", - "--config", - "{}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"INVALID_BASE_ID\\\",\\\"message\\\":\\\"baseId cannot be resolved to docId\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to update view DWSREALTESTNOSUCHID0000000000000\\\",\\\"trace_id\\\":\\\"213ee25c17840998961487395e087f\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1331, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+workflow-disable", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +workflow-disable --base-id DWSREALTESTNOSUCHID0000000000000 --workflow-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+workflow-disable", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--workflow-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"BASE_NOT_FOUND\\\",\\\"message\\\":\\\"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to disable workflow because base 'DWSREALTESTNOSUCHID0000000000000' could not be found\\\",\\\"trace_id\\\":\\\"213ee25c17840998975738372e087e\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1320, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "aitable", - "command": "+workflow-enable", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test aitable +workflow-enable --base-id DWSREALTESTNOSUCHID0000000000000 --workflow-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "aitable", - "+workflow-enable", - "--base-id", - "DWSREALTESTNOSUCHID0000000000000", - "--workflow-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"internal\",\n \"code\": 1,\n \"message\": \"[MCP_TOOL_ERROR] {\\\"data\\\":{},\\\"error\\\":{\\\"code\\\":\\\"BASE_NOT_FOUND\\\",\\\"message\\\":\\\"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid\\\",\\\"retryable\\\":false,\\\"type\\\":\\\"INPUT_ERROR\\\"},\\\"meta\\\":{},\\\"status\\\":\\\"error\\\",\\\"success\\\":true,\\\"summary\\\":\\\"Failed to enable workflow because base 'DWSREALTESTNOSUCHID0000000000000' could not be found\\\",\\\"trace_id\\\":\\\"0bab027317840998987508899e094d\\\"}\"\n }\n}", - "exit_code": 1, - "duration_ms": 1171, - "failure_category": "missing-real-aitable-fixture", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。" - }, - { - "service": "attendance", - "command": "+boss-check", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +boss-check --plan-id 999999999999 --result-id 999999999999 --time 2026-07-22T10:00:00+08:00 --result Normal --absent-min 1 --remark 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --yes --format json", - "args": [ - "attendance", - "+boss-check", - "--plan-id", - "999999999999", - "--result-id", - "999999999999", - "--time", - "2026-07-22T10:00:00+08:00", - "--result", - "Normal", - "--absent-min", - "1", - "--remark", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"C0002\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"213ee25c17840998998942184e087d\"\n }\n}", - "exit_code": 1, - "duration_ms": 1136, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+create-class", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +create-class --name 'DWS shortcut 真实测试 20260715-151724' --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --class-vo '{\"sections\":[{\"times\":[{\"checkType\":\"OnDuty\",\"checkTime\":\"09:00\",\"across\":0},{\"checkType\":\"OffDuty\",\"checkTime\":\"18:00\",\"across\":0}]}]}' --yes --format json", - "args": [ - "attendance", - "+create-class", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--owner", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--class-vo", - "{\"sections\":[{\"times\":[{\"checkType\":\"OnDuty\",\"checkTime\":\"09:00\",\"across\":0},{\"checkType\":\"OffDuty\",\"checkTime\":\"18:00\",\"across\":0}]}]}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"S0001\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"0bab027317840999009926838e090b\"\n }\n}", - "exit_code": 1, - "duration_ms": 1148, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+create-group", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +create-group --name 'DWS shortcut 真实测试 20260715-151724' --type FIXED --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --group-vo '{\"defaultClassId\":999999999999,\"workDayClassList\":[999999999999,999999999999,999999999999,999999999999,999999999999,0,0]}' --yes --format json", - "args": [ - "attendance", - "+create-group", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--type", - "FIXED", - "--owner", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--group-vo", - "{\"defaultClassId\":999999999999,\"workDayClassList\":[999999999999,999999999999,999999999999,999999999999,999999999999,0,0]}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"S0001\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2106d98117840999021121258e08b8\"\n }\n}", - "exit_code": 1, - "duration_ms": 1201, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+import-schedule", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +import-schedule --group-id 999999999999 --schedules '[{\"userId\":\"202397\",\"workDate\":\"2026-07-16 09:00:00\",\"classId\":999999999999,\"isRest\":\"N\"}]' --yes --format json", - "args": [ - "attendance", - "+import-schedule", - "--group-id", - "999999999999", - "--schedules", - "[{\"userId\":\"202397\",\"workDate\":\"2026-07-16 09:00:00\",\"classId\":999999999999,\"isRest\":\"N\"}]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"S0003\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2104a64c17840999034845189e085e\"\n }\n}", - "exit_code": 1, - "duration_ms": 1271, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+save-leave-balance", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +save-leave-balance --target __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --leave-code DWSREALTESTNOSUCHID0000000000000 --num 1 --reason 'DWS shortcut 真实测试安全负向清理' --start 2026-07-16 --end 2026-08-14 --yes --format json", - "args": [ - "attendance", - "+save-leave-balance", - "--target", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--leave-code", - "DWSREALTESTNOSUCHID0000000000000", - "--num", - "1", - "--reason", - "DWS shortcut 真实测试安全负向清理", - "--start", - "2026-07-16", - "--end", - "2026-08-14", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PERMISSION_DENIED\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2127d89817840999045751134e079c\"\n }\n}", - "exit_code": 1, - "duration_ms": 1081, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "attendance", - "command": "+update-class", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +update-class --class-id 999999999999 --name 'DWS shortcut 真实测试 20260715-151724' --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --class-vo '{\"sections\":[{\"times\":[{\"checkType\":\"OnDuty\",\"checkTime\":\"09:00\",\"across\":0},{\"checkType\":\"OffDuty\",\"checkTime\":\"18:00\",\"across\":0}]}]}' --yes --format json", - "args": [ - "attendance", - "+update-class", - "--class-id", - "999999999999", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--owner", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--class-vo", - "{\"sections\":[{\"times\":[{\"checkType\":\"OnDuty\",\"checkTime\":\"09:00\",\"across\":0},{\"checkType\":\"OffDuty\",\"checkTime\":\"18:00\",\"across\":0}]}]}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"S0001\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"0b5deb3217840999058236471e08b5\"\n }\n}", - "exit_code": 1, - "duration_ms": 1248, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+update-group", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +update-group --group-id 999999999999 --name 'DWS shortcut 真实测试 20260715-151724' --type FIXED --owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --enable-outside-check true --class-ids '[999999999999]' --group-vo '{\"defaultClassId\":999999999999,\"workDayClassList\":[999999999999,999999999999,999999999999,999999999999,999999999999,0,0]}' --yes --format json", - "args": [ - "attendance", - "+update-group", - "--group-id", - "999999999999", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--type", - "FIXED", - "--owner", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--enable-outside-check", - "true", - "--class-ids", - "[999999999999]", - "--group-vo", - "{\"defaultClassId\":999999999999,\"workDayClassList\":[999999999999,999999999999,999999999999,999999999999,999999999999,0,0]}", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"S0001\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"0b5deb3217840999068826691e087a\"\n }\n}", - "exit_code": 1, - "duration_ms": 1175, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+update-group-members", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +update-group-members --group-id 999999999999 --add-users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --remove-users '' --add-extra-users '' --remove-extra-users '' --add-depts '' --remove-depts '' --yes --format json", - "args": [ - "attendance", - "+update-group-members", - "--group-id", - "999999999999", - "--add-users", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--remove-users", - "", - "--add-extra-users", - "", - "--remove-extra-users", - "", - "--add-depts", - "", - "--remove-depts", - "", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"S0002\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"2127d89817840999081094644e07dd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1170, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "attendance", - "command": "+update-leave-type", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test attendance +update-leave-type --leave-code DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --unit day --paid --per-hours 1 --when-can-leave entry --visibility-rules '[{\"type\":\"dept\",\"visible\":[\"-1\"]}]' --yes --format json", - "args": [ - "attendance", - "+update-leave-type", - "--leave-code", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--unit", - "day", - "--paid", - "--per-hours", - "1", - "--when-can-leave", - "entry", - "--visibility-rules", - "[{\"type\":\"dept\",\"visible\":[\"-1\"]}]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type)\\n hint: Check if the resource exists or if your account has permission\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"INVALID_PARAMS\",\n \"server_key\": \"attendance-wukong\",\n \"trace_id\": \"0bb7c36217840999093924897e07fe\"\n }\n}", - "exit_code": 1, - "duration_ms": 1208, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "calendar", - "command": "+respond-event", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test calendar +respond-event --event x --response accept --yes --format json", - "args": [ - "calendar", - "+respond-event", - "--event", - "x", - "--response", - "accept", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"300000\",\n \"server_key\": \"calendar\",\n \"trace_id\": \"0bb7c36217840999105062121e07db\"\n }\n}", - "exit_code": 1, - "duration_ms": 1217, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+category-add-conversation", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +category-add-conversation --group cidDWSREALTESTNOSUCHCONV --category-ids 999999999999 --yes --format json", - "args": [ - "chat", - "+category-add-conversation", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--category-ids", - "999999999999", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories)\\n hint: Check if the resource exists or if your account has permission\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999117776203e08f9\"\n }\n}", - "exit_code": 1, - "duration_ms": 1213, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "chat", - "command": "+category-remove-conversation", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +category-remove-conversation --group cidDWSREALTESTNOSUCHCONV --category-ids 999999999999 --yes --format json", - "args": [ - "chat", - "+category-remove-conversation", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--category-ids", - "999999999999", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories)\\n hint: Check if the resource exists or if your account has permission\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999130466520e085e\"\n }\n}", - "exit_code": 1, - "duration_ms": 1309, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "chat", - "command": "+chat-add-bot", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-add-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+chat-add-bot", - "--robot-code", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__", - "--id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group)\\n hint: Check if the resource exists or if your account has permission\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"500025\",\n \"server_key\": \"bot\",\n \"trace_id\": \"2104a64c17840999144193460e08c7\"\n }\n}", - "exit_code": 1, - "duration_ms": 1273, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "chat", - "command": "+chat-audit-join", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-audit-join --group cidDWSREALTESTNOSUCHCONV --record-id 999999999999 --applicant 103262 --inviter 519019 --status AuditApprove --description 'DWS shortcut 真实测试描述,可删除' --yes --format json", - "args": [ - "chat", - "+chat-audit-join", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--record-id", - "999999999999", - "--applicant", - "103262", - "--inviter", - "519019", - "--status", - "AuditApprove", - "--description", - "DWS shortcut 真实测试描述,可删除", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"0bb7c36217840999154832733e0758\"\n }\n}", - "exit_code": 1, - "duration_ms": 1073, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "dry-run 已证明 CLI 装配了 applicantUid/inviterUid;真实后端仍报 applicantUid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+chat-mute-member", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-mute-member --group cidDWSREALTESTNOSUCHCONV --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --mute-time 1 --off --yes --format json", - "args": [ - "chat", - "+chat-mute-member", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--users", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--mute-time", - "1", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999166036583e08a3\"\n }\n}", - "exit_code": 1, - "duration_ms": 1139, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+chat-quit", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-quit --group cidDWSREALTESTNOSUCHCONV --yes --format json", - "args": [ - "chat", - "+chat-quit", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"0bb7c36217840999176728426e0779\"\n }\n}", - "exit_code": 1, - "duration_ms": 1162, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+chat-remove-bot", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-remove-bot --id DWSREALTESTNOSUCHID0000000000000 --bot-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+chat-remove-bot", - "--id", - "DWSREALTESTNOSUCHID0000000000000", - "--bot-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"500009\",\n \"server_key\": \"bot\",\n \"trace_id\": \"0b5deb3217840999189888305e08b5\"\n }\n}", - "exit_code": 1, - "duration_ms": 1220, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+chat-role-remove", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-role-remove --group cidDWSREALTESTNOSUCHCONV --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+chat-role-remove", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--role-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999200483204e079c\"\n }\n}", - "exit_code": 1, - "duration_ms": 1054, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+chat-role-remove-user", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-role-remove-user --group cidDWSREALTESTNOSUCHCONV --user __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --role-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+chat-role-remove-user", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--user", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--role-ids", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999211317952e0757\"\n }\n}", - "exit_code": 1, - "duration_ms": 1104, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+chat-transfer-owner", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-transfer-owner --group cidDWSREALTESTNOSUCHCONV --new-owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json", - "args": [ - "chat", - "+chat-transfer-owner", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--new-owner", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999222318863e087a\"\n }\n}", - "exit_code": 1, - "duration_ms": 1054, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+chat-update-icon", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-update-icon --group cidDWSREALTESTNOSUCHCONV --icon-media-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+chat-update-icon", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--icon-media-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999232478654e0819\"\n }\n}", - "exit_code": 1, - "duration_ms": 1023, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+chat-update-settings", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +chat-update-settings --group cidDWSREALTESTNOSUCHCONV --setting-key x --status 1 --yes --format json", - "args": [ - "chat", - "+chat-update-settings", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--setting-key", - "x", - "--status", - "1", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999244326971e07dd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1184, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+conversation-clear-messages", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-clear-messages --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json", - "args": [ - "chat", - "+conversation-clear-messages", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999254816721e079b\"\n }\n}", - "exit_code": 1, - "duration_ms": 1065, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+conversation-clear-red-point", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-clear-red-point --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json", - "args": [ - "chat", - "+conversation-clear-red-point", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999265511295e085f\"\n }\n}", - "exit_code": 1, - "duration_ms": 1044, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+conversation-hide", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-hide --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json", - "args": [ - "chat", - "+conversation-hide", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999276117140e079b\"\n }\n}", - "exit_code": 1, - "duration_ms": 1064, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+conversation-mark-read", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-mark-read --conversation-id cidDWSREALTESTNOSUCHCONV --message-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+conversation-mark-read", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--message-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999287654280e07bd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1162, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+conversation-mark-unread", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-mark-unread --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json", - "args": [ - "chat", - "+conversation-mark-unread", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"0bb7c36217840999298744910e0758\"\n }\n}", - "exit_code": 1, - "duration_ms": 1090, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+conversation-mute", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-mute --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json", - "args": [ - "chat", - "+conversation-mute", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999309241865e085f\"\n }\n}", - "exit_code": 1, - "duration_ms": 1052, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+conversation-mute-at-all", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-mute-at-all --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json", - "args": [ - "chat", - "+conversation-mute-at-all", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999320028068e07dd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1110, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+conversation-mute-red-envelope", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-mute-red-envelope --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json", - "args": [ - "chat", - "+conversation-mute-red-envelope", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"0bb7c36217840999330228283e07fe\"\n }\n}", - "exit_code": 1, - "duration_ms": 1001, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+conversation-set-top", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +conversation-set-top --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json", - "args": [ - "chat", - "+conversation-set-top", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--off", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999341302961e07fe\"\n }\n}", - "exit_code": 1, - "duration_ms": 1102, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+messages-add-emoji", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-add-emoji --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emoji '👍' --yes --format json", - "args": [ - "chat", - "+messages-add-emoji", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--emoji", - "👍", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999352941910e0756\"\n }\n}", - "exit_code": 1, - "duration_ms": 1170, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-add-text-emotion", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-add-text-emotion --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emotion-id DWSREALTESTNOSUCHID0000000000000 --emotion-name 'DWS测试表情' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --background-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-add-text-emotion", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--emotion-id", - "DWSREALTESTNOSUCHID0000000000000", - "--emotion-name", - "DWS测试表情", - "--text", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--background-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999362862711e08b5\"\n }\n}", - "exit_code": 1, - "duration_ms": 992, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-batch-recall-by-bot", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-batch-recall-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --keys DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-batch-recall-by-bot", - "--robot-code", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__", - "--keys", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"retryable\": true,\n \"server_error_code\": \"ERROR\",\n \"server_key\": \"bot\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"dingOpenErrcode\\\":400014,\\\"errorMsg\\\":\\\"错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;\\\"} \",\n \"trace_id\": \"2104a64c17840999373456305e0817\"\n }\n}", - "exit_code": 1, - "duration_ms": 1085, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-batch-send-by-bot", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-batch-send-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --title 'DWS shortcut 真实测试 20260715-151724' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --open-dingtalk-ids '' --at-all --yes --format json", - "args": [ - "chat", - "+messages-batch-send-by-bot", - "--robot-code", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__", - "--title", - "DWS shortcut 真实测试 20260715-151724", - "--text", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--users", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--open-dingtalk-ids", - "", - "--at-all", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"retryable\": true,\n \"server_error_code\": \"ERROR\",\n \"server_key\": \"bot\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。root.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"dingOpenErrcode\\\":400106,\\\"errorMsg\\\":\\\"错误描述: robot 不存在;解决方案:请确认 robotCode 是否正确;\\\"} \",\n \"trace_id\": \"0bb7c36217840999385748776e0757\"\n }\n}", - "exit_code": 1, - "duration_ms": 1211, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-combine-forward", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-combine-forward --src-conversation-id cidDWSREALTESTNOSUCHCONV --msg-ids DWSREALTESTNOSUCHID0000000000000 --dest-conversation-id cidDWSREALTESTNOSUCHCONV --uuid dws-shortcut-real-negative-20260715-151724 --yes --format json", - "args": [ - "chat", - "+messages-combine-forward", - "--src-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-ids", - "DWSREALTESTNOSUCHID0000000000000", - "--dest-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--uuid", - "dws-shortcut-real-negative-20260715-151724", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999396596163e08ee\"\n }\n}", - "exit_code": 1, - "duration_ms": 1075, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-create-text-emotion", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-create-text-emotion --emotion-name 'DWS测试表情' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --background-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-create-text-emotion", - "--emotion-name", - "DWS测试表情", - "--text", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--background-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"400519\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999407422891e08cd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1054, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+messages-forward", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-forward --src-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --dest-conversation-id cidDWSREALTESTNOSUCHCONV --uuid dws-shortcut-real-negative-20260715-151724 --yes --format json", - "args": [ - "chat", - "+messages-forward", - "--src-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dest-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--uuid", - "dws-shortcut-real-negative-20260715-151724", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999417966407e08ee\"\n }\n}", - "exit_code": 1, - "duration_ms": 1090, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-forward-topic", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-forward-topic --src-msg-id DWSREALTESTNOSUCHID0000000000000 --src-conversation-id cidDWSREALTESTNOSUCHCONV --src-thread-id DWSREALTESTNOSUCHID0000000000000 --dest-conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json", - "args": [ - "chat", - "+messages-forward-topic", - "--src-msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--src-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--src-thread-id", - "DWSREALTESTNOSUCHID0000000000000", - "--dest-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2127d89817840999428541474e07dd\"\n }\n}", - "exit_code": 1, - "duration_ms": 1043, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-recall", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-recall --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-recall", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999441138103e08c7\"\n }\n}", - "exit_code": 1, - "duration_ms": 1247, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-recall-by-bot", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-recall-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --group cidDWSREALTESTNOSUCHCONV --keys DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-recall-by-bot", - "--robot-code", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--keys", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"bot\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。DingOpenResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"dingOpenErrcode\\\":400012,\\\"errorMsg\\\":\\\"错误描述:无效的openConversationId此参数的示例值如\\\\\\\"cidl1B8RVUFmkXXXXXX\\\\\\\";解决方案:请检查拼写是否正确;参考链接:请参考本接口对应文档查看参数的获取方式,或者在https://open.dingtalk.com/ 查询如何获取对应的参数\\\"} \",\n \"trace_id\": \"2104a64c17840999454382709e08a3\"\n }\n}", - "exit_code": 1, - "duration_ms": 1317, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "chat", - "command": "+messages-remove-emoji", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-remove-emoji --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emoji '👍' --yes --format json", - "args": [ - "chat", - "+messages-remove-emoji", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--emoji", - "👍", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999467733059e08f9\"\n }\n}", - "exit_code": 1, - "duration_ms": 1347, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-remove-text-emotion", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-remove-text-emotion --conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --emotion-id DWSREALTESTNOSUCHID0000000000000 --emotion-name 'DWS测试表情' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --background-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-remove-text-emotion", - "--conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--emotion-id", - "DWSREALTESTNOSUCHID0000000000000", - "--emotion-name", - "DWS测试表情", - "--text", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--background-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999478923406e087f\"\n }\n}", - "exit_code": 1, - "duration_ms": 1210, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-send-by-bot", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-send-by-bot --robot-code __DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__ --group cidDWSREALTESTNOSUCHCONV --title 'DWS shortcut 真实测试 20260715-151724' --text 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --at-user-ids __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --at-open-dingtalk-ids '' --at-all --yes --format json", - "args": [ - "chat", - "+messages-send-by-bot", - "--robot-code", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_ROBOT__", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--title", - "DWS shortcut 真实测试 20260715-151724", - "--text", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--at-user-ids", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--at-open-dingtalk-ids", - "", - "--at-all", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"BUSINESS_ERROR\",\n \"server_key\": \"bot\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。DingOpenResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"400004\\\",\\\"errorMsg\\\":\\\"robotCode对应的机器人模板不存在\\\"} \",\n \"trace_id\": \"0b5deb3217840999494005921e08ef\"\n }\n}", - "exit_code": 1, - "duration_ms": 1436, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-send-card", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-send-card --receiver 103262 --yes --format json", - "args": [ - "chat", - "+messages-send-card", - "--receiver", - "103262", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999509255753e081a\"\n }\n}", - "exit_code": 1, - "duration_ms": 1502, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "dry-run 已证明 CLI 装配了 receiverUid;真实后端仍报 receiverUid/openConversationId 为空,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+messages-set-pin", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-set-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-set-pin", - "--open-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"0bb7c36217840999521117991e0758\"\n }\n}", - "exit_code": 1, - "duration_ms": 1217, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+messages-set-top", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-set-top --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-set-top", - "--open-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999532438476e0817\"\n }\n}", - "exit_code": 1, - "duration_ms": 1121, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "chat", - "command": "+messages-unset-pin", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-unset-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-unset-pin", - "--open-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"1001\",\n \"server_key\": \"im\",\n \"trace_id\": \"2104a64c17840999544428103e08ee\"\n }\n}", - "exit_code": 1, - "duration_ms": 1185, - "failure_category": "backend-or-mcp-error", - "fixability": "not-cli-fixable-first", - "diagnosis": "fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。" - }, - { - "service": "chat", - "command": "+messages-unset-top", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test chat +messages-unset-top --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "chat", - "+messages-unset-top", - "--open-conversation-id", - "cidDWSREALTESTNOSUCHCONV", - "--msg-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999554154221e08f9\"\n }\n}", - "exit_code": 1, - "duration_ms": 963, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "devapp", - "command": "+event-subscribe", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +event-subscribe --unified-app-id DWSREALTESTNOSUCHID0000000000000 --event-codes '[]' --yes --format json", - "args": [ - "devapp", - "+event-subscribe", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--event-codes", - "[]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"503\",\n \"server_key\": \"devapp\",\n \"trace_id\": \"2104a64c17840999564022020e08c7\"\n }\n}", - "exit_code": 1, - "duration_ms": 1034, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+event-unsubscribe", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +event-unsubscribe --unified-app-id DWSREALTESTNOSUCHID0000000000000 --event-codes '[]' --yes --format json", - "args": [ - "devapp", - "+event-unsubscribe", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--event-codes", - "[]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"AUTH_ERROR\",\n \"server_key\": \"devapp\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"503\\\",\\\"errorMsg\\\":\\\"当前用户没有应用事件订阅权限\\\"} \",\n \"trace_id\": \"0bb7c36217840999575698500e07db\"\n }\n}", - "exit_code": 1, - "duration_ms": 1134, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+permission-add", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +permission-add --unified-app-id DWSREALTESTNOSUCHID0000000000000 --scope-values '[]' --yes --format json", - "args": [ - "devapp", - "+permission-add", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--scope-values", - "[]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"503\",\n \"server_key\": \"devapp\",\n \"trace_id\": \"2127d89817840999587758457e079c\"\n }\n}", - "exit_code": 1, - "duration_ms": 1186, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+permission-remove", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +permission-remove --unified-app-id DWSREALTESTNOSUCHID0000000000000 --scope-values '[]' --yes --format json", - "args": [ - "devapp", - "+permission-remove", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--scope-values", - "[]", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"503\",\n \"server_key\": \"devapp\",\n \"trace_id\": \"2104a64c17840999598445247e085e\"\n }\n}", - "exit_code": 1, - "duration_ms": 1061, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+robot-config", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +robot-config --unified-app-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --brief x --desc 'DWS shortcut 真实测试描述,可删除' --icon-media-id DWSREALTESTNOSUCHID0000000000000 --outgoing-url https://example.invalid/dws-shortcut-real-test --event-callback-url https://example.invalid/dws-shortcut-real-test --mode HTTPS --skills '[]' --add-scope --disable-ssl-verify --yes --format json", - "args": [ - "devapp", - "+robot-config", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--brief", - "x", - "--desc", - "DWS shortcut 真实测试描述,可删除", - "--icon-media-id", - "DWSREALTESTNOSUCHID0000000000000", - "--outgoing-url", - "https://example.invalid/dws-shortcut-real-test", - "--event-callback-url", - "https://example.invalid/dws-shortcut-real-test", - "--mode", - "HTTPS", - "--skills", - "[]", - "--add-scope", - "--disable-ssl-verify", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"AUTH_ERROR\",\n \"server_key\": \"devapp\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"503\\\",\\\"errorMsg\\\":\\\"当前用户没有应用机器人配置权限\\\"} \",\n \"trace_id\": \"0bb7c36217840999609828961e07db\"\n }\n}", - "exit_code": 1, - "duration_ms": 1167, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+robot-disable", - "risk": "high-risk-write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +robot-disable --unified-app-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "devapp", - "+robot-disable", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"AUTH_ERROR\",\n \"server_key\": \"devapp\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"503\\\",\\\"errorMsg\\\":\\\"当前用户没有应用机器人配置权限\\\"} \",\n \"trace_id\": \"2104a64c17840999620461113e08ee\"\n }\n}", - "exit_code": 1, - "duration_ms": 1062, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+robot-enable", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +robot-enable --unified-app-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "devapp", - "+robot-enable", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"AUTH_ERROR\",\n \"server_key\": \"devapp\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"503\\\",\\\"errorMsg\\\":\\\"当前用户没有应用机器人配置权限\\\"} \",\n \"trace_id\": \"0bb7c36217840999632641525e0758\"\n }\n}", - "exit_code": 1, - "duration_ms": 1306, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+security-config", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +security-config --unified-app-id DWSREALTESTNOSUCHID0000000000000 --ip-whitelist 127.0.0.1 --redirect-urls https://example.invalid/dws-shortcut-real-test --sso-urls https://example.invalid/dws-shortcut-real-test --yes --format json", - "args": [ - "devapp", - "+security-config", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--ip-whitelist", - "127.0.0.1", - "--redirect-urls", - "https://example.invalid/dws-shortcut-real-test", - "--sso-urls", - "https://example.invalid/dws-shortcut-real-test", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"503\",\n \"server_key\": \"devapp\",\n \"trace_id\": \"2104a64c17840999645522046e0817\"\n }\n}", - "exit_code": 1, - "duration_ms": 1205, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "devapp", - "command": "+version-create", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +version-create --unified-app-id DWSREALTESTNOSUCHID0000000000000 --version 0.0.1-dws-real-test --desc 'DWS shortcut 真实测试描述,可删除' --yes --format json", - "args": [ - "devapp", - "+version-create", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--version", - "0.0.1-dws-real-test", - "--desc", - "DWS shortcut 真实测试描述,可删除", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"BUSINESS_ERROR\",\n \"server_key\": \"devapp\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"450001\\\"} \",\n \"trace_id\": \"0b5deb3217840999656705920e0853\"\n }\n}", - "exit_code": 1, - "duration_ms": 1157, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "devapp", - "command": "+version-publish", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test devapp +version-publish --unified-app-id DWSREALTESTNOSUCHID0000000000000 --version-id DWSREALTESTNOSUCHID0000000000000 --confirmed-sensitive --approver-user-id __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json", - "args": [ - "devapp", - "+version-publish", - "--unified-app-id", - "DWSREALTESTNOSUCHID0000000000000", - "--version-id", - "DWSREALTESTNOSUCHID0000000000000", - "--confirmed-sensitive", - "--approver-user-id", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"BUSINESS_ERROR\",\n \"server_key\": \"devapp\",\n \"technical_detail\": \"iPaaS 调用失败: 调用远程服务业务异常, 出参校验不通过。ServiceResult.success当前值:false, 预期值:true,所有值:{\\\"success\\\":false,\\\"errorCode\\\":\\\"450001\\\"} \",\n \"trace_id\": \"2127d89817840999667906017e075d\"\n }\n}", - "exit_code": 1, - "duration_ms": 1071, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "ding", - "command": "+send-by-message", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test ding +send-by-message --group cidDWSREALTESTNOSUCHCONV --message-id DWSREALTESTNOSUCHID0000000000000 --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --type FIXED --uuid dws-shortcut-real-negative-20260715-151724 --yes --format json", - "args": [ - "ding", - "+send-by-message", - "--group", - "cidDWSREALTESTNOSUCHCONV", - "--message-id", - "DWSREALTESTNOSUCHID0000000000000", - "--users", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__", - "--type", - "FIXED", - "--uuid", - "dws-shortcut-real-negative-20260715-151724", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"PARAM_ERROR\",\n \"server_key\": \"im\",\n \"trace_id\": \"0b5deb3217840999678466213e0853\"\n }\n}", - "exit_code": 1, - "duration_ms": 1058, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "doc", - "command": "+comment-create-inline", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test doc +comment-create-inline --node DWSREALTESTNOSUCHID0000000000000 --content 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --block-id DWSREALTESTNOSUCHID0000000000000 --start 0 --end 1 --selected-text 'DWS测试' --mention x --yes --format json", - "args": [ - "doc", - "+comment-create-inline", - "--node", - "DWSREALTESTNOSUCHID0000000000000", - "--content", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--block-id", - "DWSREALTESTNOSUCHID0000000000000", - "--start", - "0", - "--end", - "1", - "--selected-text", - "DWS测试", - "--mention", - "x", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment)\\n hint: Document may have been deleted or moved\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"invalidRequest.inputArgs.invalid\",\n \"server_key\": \"doc-comment\",\n \"trace_id\": \"2127d89817840999689931893e079c\"\n }\n}", - "exit_code": 1, - "duration_ms": 1183, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "doc", - "command": "+template-apply", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test doc +template-apply --template-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --folder DWSREALTESTNOSUCHID0000000000000 --workspace DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "doc", - "+template-apply", - "--template-id", - "DWSREALTESTNOSUCHID0000000000000", - "--name", - "DWS shortcut 真实测试 20260715-151724", - "--folder", - "DWSREALTESTNOSUCHID0000000000000", - "--workspace", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template)\\n hint: Check if the resource exists or if your account has permission\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"invalidRequest.inputArgs.invalid\",\n \"server_key\": \"doc\",\n \"trace_id\": \"2127d89817840999701186954e0757\"\n }\n}", - "exit_code": 1, - "duration_ms": 1093, - "failure_category": "auth-or-permission", - "fixability": "not-cli-fixable", - "diagnosis": "真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。" - }, - { - "service": "minutes", - "command": "+record-pause", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test minutes +record-pause --id DWSREALTESTNOSUCHID0000000000000 --session-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "minutes", - "+record-pause", - "--id", - "DWSREALTESTNOSUCHID0000000000000", - "--session-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"aiAgentTestRunCmdUnknownError\",\n \"server_key\": \"minutes\",\n \"trace_id\": \"0bb7c36217840999713124987e0757\"\n }\n}", - "exit_code": 1, - "duration_ms": 1233, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "minutes", - "command": "+record-resume", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test minutes +record-resume --id DWSREALTESTNOSUCHID0000000000000 --session-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "minutes", - "+record-resume", - "--id", - "DWSREALTESTNOSUCHID0000000000000", - "--session-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"aiAgentTestRunCmdUnknownError\",\n \"server_key\": \"minutes\",\n \"trace_id\": \"0bb7c36217840999724452848e0758\"\n }\n}", - "exit_code": 1, - "duration_ms": 1074, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "minutes", - "command": "+record-stop", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test minutes +record-stop --id DWSREALTESTNOSUCHID0000000000000 --session-id DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "minutes", - "+record-stop", - "--id", - "DWSREALTESTNOSUCHID0000000000000", - "--session-id", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音)\\n hint: Use --verbose for detailed error logs\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"aiAgentTestRunCmdUnknownError\",\n \"server_key\": \"minutes\",\n \"trace_id\": \"2127d89817840999735397508e0757\"\n }\n}", - "exit_code": 1, - "duration_ms": 1130, - "failure_category": "input-or-business-validation", - "fixability": "test-input-or-backend-rule", - "diagnosis": "命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。" - }, - { - "service": "oa", - "command": "+approve-by", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test oa +approve-by --keyword __DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__ --comment 'DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。' --yes --format json", - "args": [ - "oa", - "+approve-by", - "--keyword", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__", - "--comment", - "DWS shortcut 真实测试 20260715-151724,安全负向输入,请忽略。", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"没找到待审批单据:待我处理的审批里没有标题/单号包含 \\\"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__\\\" 的单据。\"\n }\n}", - "exit_code": 3, - "duration_ms": 1318, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "wiki", - "command": "+node-copy", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test wiki +node-copy --workspace DWSREALTESTNOSUCHID0000000000000 --node DWSREALTESTNOSUCHID0000000000000 --folder DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "wiki", - "+node-copy", - "--workspace", - "DWSREALTESTNOSUCHID0000000000000", - "--node", - "DWSREALTESTNOSUCHID0000000000000", - "--folder", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document)\\n hint: Document may have been deleted or moved\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"invalidRequest.inputArgs.invalid\",\n \"server_key\": \"doc\",\n \"trace_id\": \"0bb7c36217840999769818495e0779\"\n }\n}", - "exit_code": 1, - "duration_ms": 1000, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "wiki", - "command": "+node-move", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test wiki +node-move --workspace DWSREALTESTNOSUCHID0000000000000 --node DWSREALTESTNOSUCHID0000000000000 --folder DWSREALTESTNOSUCHID0000000000000 --yes --format json", - "args": [ - "wiki", - "+node-move", - "--workspace", - "DWSREALTESTNOSUCHID0000000000000", - "--node", - "DWSREALTESTNOSUCHID0000000000000", - "--folder", - "DWSREALTESTNOSUCHID0000000000000", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"api\",\n \"code\": 1,\n \"hint\": \"The API returned a business-level error. Check required parameters and values.\",\n \"message\": \"[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document)\\n hint: Document may have been deleted or moved\",\n \"operation\": \"tools/call\",\n \"reason\": \"business_error\",\n \"server_error_code\": \"invalidRequest.inputArgs.invalid\",\n \"server_key\": \"doc\",\n \"trace_id\": \"0bb7c36217840999780286509e07fe\"\n }\n}", - "exit_code": 1, - "duration_ms": 1053, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "wiki", - "command": "+wiki-new-doc", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-error", - "input": "/private/tmp/dws-real-test wiki +wiki-new-doc --space __DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__ --title 'DWS shortcut 真实测试 20260715-151724' --yes --format json", - "args": [ - "wiki", - "+wiki-new-doc", - "--space", - "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__", - "--title", - "DWS shortcut 真实测试 20260715-151724", - "--yes", - "--format", - "json" - ], - "stdout": "", - "stderr": "{\n \"error\": {\n \"category\": \"validation\",\n \"code\": 3,\n \"message\": \"没找到名为 \\\"__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__\\\" 的知识库;换个更完整/精确的空间名再试。\"\n }\n}", - "exit_code": 3, - "duration_ms": 1152, - "failure_category": "missing-real-resource", - "fixability": "not-cli-fixable-without-fixture", - "diagnosis": "真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。" - }, - { - "service": "todo", - "command": "+todo-done", - "risk": "write", - "method": "real-backend-safe-negative-write; no --mock; no --dry-run", - "status": "real-ok", - "input": "/private/tmp/dws-real-test todo +todo-done --task 'DWS shortcut 真实测试待办 20260715-160449' --yes --format json", - "args": [ - "todo", - "+todo-done", - "--task", - "DWS shortcut 真实测试待办 20260715-160449", - "--yes", - "--format", - "json" - ], - "stdout": "{\n \"result\": {\n \"success\": true\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 1771, - "setup": { - "purpose": "为 todo +todo-done 创建当前账号自己的临时待办 fixture", - "input": "/private/tmp/dws-real-test todo +remind --task 'DWS shortcut 真实测试待办 20260715-160449' --at 2026-07-22T10:15:00+08:00 --yes --format json", - "stdout": "{\n \"arguments\": [],\n \"result\": {\n \"subject\": \"DWS shortcut 真实测试待办 20260715-160449\",\n \"taskId\": \"55055639461\"\n },\n \"success\": true\n}", - "stderr": "", - "exit_code": 0, - "duration_ms": 2611 - } - } - ] -} \ No newline at end of file diff --git a/internal/app/access_token_manager_test.go b/internal/app/access_token_manager_test.go new file mode 100644 index 00000000..e996b7be --- /dev/null +++ b/internal/app/access_token_manager_test.go @@ -0,0 +1,223 @@ +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type tokenManagerSnapshotProvider struct { + load func() (*authpkg.TokenData, error) +} + +func (p tokenManagerSnapshotProvider) GetAccessToken(context.Context) (string, error) { + data, err := p.load() + if err != nil || data == nil { + return "", err + } + return data.AccessToken, nil +} + +func (p tokenManagerSnapshotProvider) GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) { + return p.load() +} + +type tokenManagerLegacyGetter struct { + token string + err error +} + +func (g tokenManagerLegacyGetter) GetToken() (string, string, error) { + return g.token, "file", g.err +} + +func installTokenManagerFakes(t *testing.T, load func() (*authpkg.TokenData, error)) { + t.Helper() + oldProvider, oldLegacy := newAccessTokenProvider, newLegacyTokenManager + oldEdition := edition.Get() + edition.Override(&edition.Hooks{}) + newAccessTokenProvider = func(string) accessTokenGetter { + return tokenManagerSnapshotProvider{load: load} + } + newLegacyTokenManager = func(string) legacyTokenGetter { + return tokenManagerLegacyGetter{err: authpkg.ErrTokenDataNotFound} + } + t.Cleanup(func() { + newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldLegacy + edition.Override(oldEdition) + }) +} + +func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + token := "token-a" + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + + manager := NewTokenManager() + first, err := manager.Get(context.Background(), configDir, "") + if err != nil || first.AccessToken != "token-a" { + t.Fatalf("first token = %#v, %v", first, err) + } + second, err := manager.Get(context.Background(), configDir, "") + if err != nil || second.AccessToken != "token-a" || calls.Load() != 1 { + t.Fatalf("cached token = %#v, %v, calls=%d", second, err, calls.Load()) + } + + token = "token-b" + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + rotated, err := manager.Get(context.Background(), configDir, "") + if err != nil || rotated.AccessToken != "token-b" || calls.Load() != 2 { + t.Fatalf("rotated token = %#v, %v, calls=%d", rotated, err, calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerDoesNotCacheWithoutExpiryOrRevision(t *testing.T) { + configDir := t.TempDir() + var calls atomic.Int32 + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: "token"}, nil + }) + manager := NewTokenManager() + for range 2 { + if _, err := manager.Get(context.Background(), configDir, ""); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 2 { + t.Fatalf("provider calls = %d, want 2", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerTreatsMalformedMarkerAsUncacheable(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "token.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + manager := NewTokenManager() + for range 2 { + if snapshot, err := manager.Get(context.Background(), configDir, ""); err != nil || snapshot.AccessToken != "token" { + t.Fatalf("snapshot = %#v, error = %v", snapshot, err) + } + } + if calls.Load() != 2 { + t.Fatalf("provider calls = %d, want 2", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerDoesNotCacheOpaqueEditionStorageWithProviderFallback(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + edition.Override(&edition.Hooks{ + LoadToken: func(string) ([]byte, error) { return nil, nil }, + TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) { + return fallback() + }, + }) + manager := NewTokenManager() + for range 2 { + if _, err := manager.Get(context.Background(), configDir, ""); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 2 { + t.Fatalf("provider calls = %d, want 2", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerCoalescesConcurrentLoads(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + release := make(chan struct{}) + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + <-release + return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + manager := NewTokenManager() + const workers = 8 + var wg sync.WaitGroup + wg.Add(workers) + errs := make(chan error, workers) + for range workers { + go func() { + defer wg.Done() + _, err := manager.Get(context.Background(), configDir, "") + errs <- err + }() + } + for calls.Load() == 0 { + time.Sleep(time.Millisecond) + } + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if calls.Load() != 1 { + t.Fatalf("provider calls = %d, want 1", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerPreservesProviderFailure(t *testing.T) { + configDir := t.TempDir() + want := errors.New("keychain permission denied") + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { return nil, want }) + _, err := NewTokenManager().Get(context.Background(), configDir, "") + if !errors.Is(err, want) { + t.Fatalf("error = %v, want cause %v", err, want) + } + if errors.Is(err, authpkg.ErrTokenDataNotFound) { + t.Fatalf("provider failure was misclassified as missing credentials: %v", err) + } +} + +func TestCrossPlatformCoverageTokenResolutionErrorOnlyClassifiesTrueMissingCredential(t *testing.T) { + missing := tokenResolutionError(authpkg.ErrTokenDataNotFound) + var typed interface{ Unwrap() error } + if !errors.As(missing, &typed) || !errors.Is(missing, authpkg.ErrTokenDataNotFound) { + t.Fatalf("missing error = %v", missing) + } + want := errors.New("decrypt failed") + if got := tokenResolutionError(want); !errors.Is(got, want) || errors.Is(got, authpkg.ErrTokenDataNotFound) { + t.Fatalf("storage error = %v", got) + } + if got := tokenResolutionError(context.Canceled); !errors.Is(got, context.Canceled) { + t.Fatalf("cancellation = %v", got) + } +} diff --git a/internal/app/access_token_resolve.go b/internal/app/access_token_resolve.go index e9a24e84..c5c969d9 100644 --- a/internal/app/access_token_resolve.go +++ b/internal/app/access_token_resolve.go @@ -21,19 +21,61 @@ import ( "log/slog" "path/filepath" "strings" + "sync" + "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) +const accessTokenRefreshWindow = 5 * time.Minute + type legacyTokenGetter interface { GetToken() (string, string, error) } +type accessTokenSnapshotGetter interface { + GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) +} + +// AccessTokenSnapshot is the minimal bearer view needed by the process cache. +// Refresh-token material never leaves the auth package. +type AccessTokenSnapshot struct { + AccessToken string + ExpiresAt time.Time + Source string +} + +type tokenManagerKey struct { + configDir string + profile string +} + +type tokenManagerEntry struct { + mu sync.Mutex + snapshot AccessTokenSnapshot + revision string +} + +// TokenManager is the only process cache for user access tokens. Cache entries +// are isolated by config directory and profile, expiry-aware, and invalidated +// by the credential publication marker written by auth storage. +type TokenManager struct { + mu sync.Mutex + entries map[tokenManagerKey]*tokenManagerEntry + now func() time.Time +} + +func NewTokenManager() *TokenManager { + return &TokenManager{entries: make(map[tokenManagerKey]*tokenManagerEntry), now: time.Now} +} + +var runtimeTokenManager = NewTokenManager() + var ( newAccessTokenProvider = func(configDir string) accessTokenGetter { - disc := slog.New(slog.NewTextHandler(io.Discard, nil)) - provider := authpkg.NewOAuthProvider(configDir, disc) + discard := slog.New(slog.NewTextHandler(io.Discard, nil)) + provider := authpkg.NewOAuthProvider(configDir, discard) configureOAuthProviderCompatibility(provider, configDir) return provider } @@ -44,64 +86,218 @@ var ( } ) -// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying -// the same host compatibility hooks as MCP. It mirrors the former body of -// getCachedRuntimeToken (excluding process-level cache and timing). -func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) { - provider := newAccessTokenProvider(configDir) - token, tokenErr := provider.GetAccessToken(ctx) - if tokenErr == nil && strings.TrimSpace(token) != "" { - return strings.TrimSpace(token), nil - } - if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { - return "", tokenErr - } - if strings.TrimSpace(authpkg.RuntimeProfile()) != "" { - if tokenErr != nil { - return "", tokenErr - } - return "", nil - } - manager := newLegacyTokenManager(configDir) - if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" { - return strings.TrimSpace(leg), nil - } - if tokenErr != nil { - return "", tokenErr - } - return "", nil -} - -// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should -// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches -// the active edition config directory, the same process-cached path as MCP is used. -// Otherwise tokens are loaded from configDir with host compatibility hooks applied. -func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) { - if t := strings.TrimSpace(explicitToken); t != "" { - return t, nil +// Get resolves an access token for the active runtime profile. +func (m *TokenManager) Get(ctx context.Context, configDir, explicitToken string) (AccessTokenSnapshot, error) { + if token := strings.TrimSpace(explicitToken); token != "" { + return AccessTokenSnapshot{AccessToken: token, Source: "explicit"}, nil } if strings.TrimSpace(configDir) == "" { - return "", fmt.Errorf("config directory is empty") + return AccessTokenSnapshot{}, fmt.Errorf("config directory is empty") } - if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) { - if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" { - return tok, nil + key := tokenManagerKey{ + configDir: canonicalTokenConfigDir(configDir), + profile: strings.TrimSpace(authpkg.RuntimeProfile()), + } + entry := m.entry(key) + entry.mu.Lock() + defer entry.mu.Unlock() + + now := time.Now() + if m != nil && m.now != nil { + now = m.now() + } + revision, present, err := authpkg.ReadTokenMarkerRevision(configDir) + if err != nil { + return AccessTokenSnapshot{}, err + } + if tokenSnapshotUsable(entry.snapshot, now) && present && revision != "" && revision == entry.revision { + return entry.snapshot, nil + } + + // Treat the marker and credential as one optimistic snapshot. A concurrent + // login/refresh between the reads causes a retry instead of caching stale A + // under the publication marker for B. + for attempt := 0; attempt < 4; attempt++ { + beforeRevision, beforePresent, err := authpkg.ReadTokenMarkerRevision(configDir) + if err != nil { + return AccessTokenSnapshot{}, err } - return "", noCredentialsError() + snapshot, err := resolveTokenSnapshotWithEdition(ctx, configDir, key.profile) + if err != nil { + return AccessTokenSnapshot{}, err + } + afterRevision, afterPresent, err := authpkg.ReadTokenMarkerRevision(configDir) + if err != nil { + return AccessTokenSnapshot{}, err + } + if beforePresent != afterPresent || beforeRevision != afterRevision { + continue + } + if strings.TrimSpace(snapshot.AccessToken) == "" { + return AccessTokenSnapshot{}, noCredentialsError() + } + if tokenSnapshotUsable(snapshot, now) && afterPresent && afterRevision != "" { + entry.snapshot = snapshot + entry.revision = afterRevision + } else { + entry.snapshot = AccessTokenSnapshot{} + entry.revision = "" + } + return snapshot, nil } - tok, err := resolveAccessTokenFromDir(ctx, configDir) + return AccessTokenSnapshot{}, fmt.Errorf("token publication changed repeatedly while resolving credentials") +} + +func (m *TokenManager) entry(key tokenManagerKey) *tokenManagerEntry { + m.mu.Lock() + defer m.mu.Unlock() + if m.entries == nil { + m.entries = make(map[tokenManagerKey]*tokenManagerEntry) + } + entry := m.entries[key] + if entry == nil { + entry = &tokenManagerEntry{} + m.entries[key] = entry + } + return entry +} + +func (m *TokenManager) Invalidate() { + if m == nil { + return + } + m.mu.Lock() + m.entries = make(map[tokenManagerKey]*tokenManagerEntry) + m.mu.Unlock() +} + +func resolveTokenSnapshotWithEdition(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) { + hooks := edition.Get() + opaqueStorage := hooks.LoadToken != nil || hooks.SaveToken != nil || hooks.DeleteToken != nil + provider := hooks.TokenProvider + if provider == nil { + snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, profile) + if err != nil { + return AccessTokenSnapshot{}, err + } + // Opaque edition storage hooks have no publication-revision contract. + // Resolve them on every logical request instead of caching a token that + // may be replaced outside the default auth store. + if opaqueStorage { + snapshot.ExpiresAt = time.Time{} + } + return snapshot, nil + } + var fallbackSnapshot AccessTokenSnapshot + var fallbackCalled bool + token, err := provider(ctx, func() (string, error) { + fallbackCalled = true + var fallbackErr error + fallbackSnapshot, fallbackErr = resolveAccessTokenSnapshotFromDir(ctx, configDir, profile) + if fallbackErr != nil { + return "", fallbackErr + } + return fallbackSnapshot.AccessToken, nil + }) + if err != nil { + return AccessTokenSnapshot{}, fmt.Errorf("edition token provider: %w", err) + } + token = strings.TrimSpace(token) + if token == "" { + return AccessTokenSnapshot{}, noCredentialsError() + } + if fallbackCalled && token == fallbackSnapshot.AccessToken { + if opaqueStorage { + fallbackSnapshot.ExpiresAt = time.Time{} + } + return fallbackSnapshot, nil + } + // Edition providers expose no lifetime metadata, so resolve them on every + // logical request instead of recreating a process-lifetime string cache. + return AccessTokenSnapshot{AccessToken: token, Source: "edition"}, nil +} + +func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) { + provider := newAccessTokenProvider(configDir) + if snapshotProvider, ok := provider.(accessTokenSnapshotGetter); ok { + data, err := snapshotProvider.GetTokenSnapshot(ctx) + if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" { + return AccessTokenSnapshot{ + AccessToken: strings.TrimSpace(data.AccessToken), + ExpiresAt: data.ExpiresAt, + Source: "oauth", + }, nil + } + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return AccessTokenSnapshot{}, err + } + if strings.TrimSpace(profile) != "" { + return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound + } + return resolveLegacyToken(configDir, err) + } + + token, err := provider.GetAccessToken(ctx) + if err == nil && strings.TrimSpace(token) != "" { + return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: "oauth_compat"}, nil + } + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return AccessTokenSnapshot{}, err + } + if strings.TrimSpace(profile) != "" { + return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound + } + return resolveLegacyToken(configDir, err) +} + +func resolveLegacyToken(configDir string, oauthErr error) (AccessTokenSnapshot, error) { + token, source, err := newLegacyTokenManager(configDir).GetToken() + if err == nil && strings.TrimSpace(token) != "" { + return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: source}, nil + } + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return AccessTokenSnapshot{}, err + } + if oauthErr != nil { + return AccessTokenSnapshot{}, oauthErr + } + return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound +} + +func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) { + snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, authpkg.RuntimeProfile()) if err != nil { return "", err } - if tok != "" { - return tok, nil + return snapshot.AccessToken, nil +} + +// ResolveAuxiliaryAccessToken resolves every non-runner bearer token through +// the same TokenManager used by MCP tool calls. +func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) { + snapshot, err := runtimeTokenManager.Get(ctx, configDir, explicitToken) + if err != nil { + return "", err } - return "", noCredentialsError() + return snapshot.AccessToken, nil +} + +func tokenSnapshotUsable(snapshot AccessTokenSnapshot, now time.Time) bool { + return strings.TrimSpace(snapshot.AccessToken) != "" && + !snapshot.ExpiresAt.IsZero() && + now.Before(snapshot.ExpiresAt.Add(-accessTokenRefreshWindow)) +} + +func canonicalTokenConfigDir(configDir string) string { + if absolute, err := filepath.Abs(configDir); err == nil { + return filepath.Clean(absolute) + } + return filepath.Clean(configDir) } func noCredentialsError() error { if edition.Get().IsEmbedded { - return fmt.Errorf("认证信息已失效,请重新认证") + return fmt.Errorf("认证信息已失效,请重新认证: %w", authpkg.ErrTokenDataNotFound) } - return fmt.Errorf("no credentials found, run: dws auth login") + return fmt.Errorf("no credentials found, run: dws auth login: %w", authpkg.ErrTokenDataNotFound) } diff --git a/internal/app/app_small_coverage_test.go b/internal/app/app_small_coverage_test.go index ee6e313e..a81497b7 100644 --- a/internal/app/app_small_coverage_test.go +++ b/internal/app/app_small_coverage_test.go @@ -34,6 +34,10 @@ func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) { return g.token, g.err } +func (g fakeAccessTokenGetter) ForceRefreshRejectedToken(context.Context, string) (string, error) { + return g.token, g.err +} + type fakeLegacyTokenGetter struct { token string err error @@ -212,14 +216,16 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) { if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) { t.Fatalf("decryption error = %v", err) } - newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} } + newAccessTokenProvider = func(string) accessTokenGetter { + return fakeAccessTokenGetter{err: authpkg.ErrTokenDataNotFound} + } newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} } if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" { t.Fatalf("legacy token = %q, %v", got, err) } authpkg.SetRuntimeProfile("corp:user") t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) - if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || err == nil || err.Error() != "missing" { + if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) { t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err) } authpkg.SetRuntimeProfile("") @@ -246,10 +252,10 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) { } func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) { - oldMark, oldFactory := markAccessTokenStale, newRefreshProvider + oldLoad, oldFactory := loadRefreshTokenData, newRefreshProvider oldStop := stopStdio t.Cleanup(func() { - markAccessTokenStale, newRefreshProvider = oldMark, oldFactory + loadRefreshTokenData, newRefreshProvider = oldLoad, oldFactory stopStdio = oldStop stdioMu.Lock() stdioClients = make(map[string]*transport.StdioClient) @@ -257,11 +263,13 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) }) fail := errors.New("failure") _ = oldFactory(t.TempDir()) - markAccessTokenStale = func(string) error { return fail } + loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { return nil, fail } if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) { - t.Fatalf("mark stale error = %v", err) + t.Fatalf("load rejected token error = %v", err) + } + loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { + return &authpkg.TokenData{AccessToken: "rejected"}, nil } - markAccessTokenStale = func(string) error { return nil } for _, tc := range []struct { getter fakeAccessTokenGetter want string @@ -270,7 +278,7 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {getter: fakeAccessTokenGetter{token: " "}, want: "empty"}, {getter: fakeAccessTokenGetter{token: " refreshed "}}, } { - newRefreshProvider = func(string) accessTokenGetter { return tc.getter } + newRefreshProvider = func(string) rejectedAccessTokenRefresher { return tc.getter } got, err := ForceRefreshAccessToken(context.Background(), "config") if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { t.Fatalf("refresh error = %v, want %q", err, tc.want) diff --git a/internal/app/auth_refresh_retry.go b/internal/app/auth_refresh_retry.go index 3fbd63d1..8712f16d 100644 --- a/internal/app/auth_refresh_retry.go +++ b/internal/app/auth_refresh_retry.go @@ -15,6 +15,15 @@ package app import ( "context" + "errors" + "fmt" + "log/slog" + "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/logging" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry" ) // authRetryingKey marks a context that has already attempted one @@ -23,8 +32,35 @@ import ( // to the user instead. type authRetryingKeyType struct{} +type authRefreshFailureError struct { + rejection error + refresh error +} + +func (e *authRefreshFailureError) Error() string { + return "automatic access token refresh failed" +} + +func (e *authRefreshFailureError) Unwrap() []error { + if e == nil { + return nil + } + return []error{e.rejection, e.refresh} +} + var authRetryingKey = authRetryingKeyType{} +var ( + runnerForceRefreshRejectedAccessToken = forceRefreshRejectedAccessToken + runnerExecuteAuthRetry func(*runtimeRunner, context.Context, string, executor.Invocation) (executor.Result, error) +) + +func init() { + runnerExecuteAuthRetry = func(r *runtimeRunner, ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) { + return r.executeInvocation(ctx, endpoint, invocation) + } +} + // IsAuthRetrying reports whether the current context is already inside an // AuthRefreshRequired retry. Mirrors IsPatRetrying. func IsAuthRetrying(ctx context.Context) bool { @@ -34,3 +70,103 @@ func IsAuthRetrying(ctx context.Context) bool { v, _ := ctx.Value(authRetryingKey).(bool) return v } + +func withAuthRetrying(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, authRetryingKey, true) +} + +func authRefreshLogger() *slog.Logger { + if logger := FileLoggerInstance(); logger != nil { + return logger + } + return slog.Default() +} + +func (r *runtimeRunner) managesRuntimeOAuth(hasPluginAuth bool) bool { + if r == nil || hasPluginAuth { + return false + } + return r.globalFlags == nil || strings.TrimSpace(r.globalFlags.Token) == "" +} + +// retryAuthRefreshRequired consumes only the explicit edition marker. It does +// not infer retryability from free text, generic auth categories, HTTP 403, or +// ordinary business errors. +func (r *runtimeRunner) retryAuthRefreshRequired( + ctx context.Context, + endpoint string, + invocation executor.Invocation, + rejectedAccessToken string, + markerErr error, + hasPluginAuth bool, +) (executor.Result, error, bool) { + marker, marked := authretry.As(markerErr) + if !marked { + return executor.Result{}, nil, false + } + cause := marker.Cause + if cause == nil { + cause = markerErr + } + + // Explicit --token and plugin credentials are not backed by the default + // OAuth refresh store. Preserve the overlay cause without mutating an + // unrelated persisted login. + if !r.managesRuntimeOAuth(hasPluginAuth) { + return executor.Result{}, cause, true + } + if IsAuthRetrying(ctx) { + authRefreshLogger().Warn("auth.runtime.refresh.retry_exhausted", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + ) + return executor.Result{}, cause, true + } + + if _, err := runnerForceRefreshRejectedAccessToken(ctx, defaultConfigDir(), rejectedAccessToken); err != nil { + // Keep every log credential-safe. The returned error chain retains the + // complete cause for in-process diagnosis; even DWS_DEBUG_AUTH must not + // serialize an OAuth response body or other attacker-controlled text. + authRefreshLogger().Warn("auth.runtime.refresh.failed", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + "stage", "force_refresh_rejected_token", + "error_type", fmt.Sprintf("%T", err), + ) + logging.AuthDebug("auth.runtime.refresh.failed.detail", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + "stage", "force_refresh_rejected_token", + "error_type", fmt.Sprintf("%T", err), + ) + combined := &authRefreshFailureError{rejection: cause, refresh: err} + return executor.Result{}, apperrors.NewAuth( + "automatic access token refresh failed", + apperrors.WithOperation("auth/token/refresh"), + apperrors.WithReason("auth_refresh_failed"), + apperrors.WithHint("本地凭证已保留;可稍后重试,若持续失败请查看认证诊断日志。"), + apperrors.WithCause(combined), + ), true + } + + logging.AuthDebug("auth.runtime.refresh.succeeded", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + ) + result, err := runnerExecuteAuthRetry(r, withAuthRetrying(ctx), endpoint, invocation) + return result, err, true +} + +// isRefreshableTransportAuthError deliberately excludes HTTP/RPC 403 and +// generic CategoryAuth values. OnAuthError may request a refresh only for an +// exact transport-level unauthorized signal. +func isRefreshableTransportAuthError(err error) bool { + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth { + return false + } + return typed.Reason == "http_401" || typed.RPCCode == 401 +} diff --git a/internal/app/auth_refresh_retry_runner_test.go b/internal/app/auth_refresh_retry_runner_test.go new file mode 100644 index 00000000..640b1f99 --- /dev/null +++ b/internal/app/auth_refresh_retry_runner_test.go @@ -0,0 +1,354 @@ +// 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" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func installAuthRefreshRunnerSeams(t *testing.T) { + t.Helper() + previousHooks := edition.Get() + previousCall := runnerCallTool + previousPreflight := runnerPreflightDocDownload + previousRefresh := runnerForceRefreshRejectedAccessToken + previousRetry := runnerExecuteAuthRetry + previousCapture := runnerCaptureRuntimeFailure + previousProfile := authpkg.RuntimeProfile() + + pluginAuthMu.Lock() + previousPlugins := pluginAuthRegistry + pluginAuthRegistry = make(map[string]*PluginAuth) + pluginAuthMu.Unlock() + + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + return nil + } + runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {} + authpkg.SetRuntimeProfile("") + runtimeTokenManager.Invalidate() + t.Setenv("DWS_CONFIG_DIR", "") + t.Setenv("DWS_DEBUG_AUTH", "0") + + t.Cleanup(func() { + edition.Override(previousHooks) + runnerCallTool = previousCall + runnerPreflightDocDownload = previousPreflight + runnerForceRefreshRejectedAccessToken = previousRefresh + runnerExecuteAuthRetry = previousRetry + runnerCaptureRuntimeFailure = previousCapture + authpkg.SetRuntimeProfile(previousProfile) + runtimeTokenManager.Invalidate() + pluginAuthMu.Lock() + pluginAuthRegistry = previousPlugins + pluginAuthMu.Unlock() + }) +} + +func authRefreshTestRunner(flags *GlobalFlags) *runtimeRunner { + return &runtimeRunner{ + transport: transport.NewClient(nil), + globalFlags: flags, + auditSink: audit.NopSink{}, + } +} + +func authRefreshTestInvocation() executor.Invocation { + return executor.Invocation{ + CanonicalProduct: "auth-retry-test-product", + Tool: "test_tool", + Params: map[string]any{"value": "safe"}, + } +} + +func authRefreshTokenHooks(configDir string, token *string, classify func(map[string]any) error) *edition.Hooks { + return &edition.Hooks{ + ConfigDir: func() string { return configDir }, + TokenProvider: func(context.Context, func() (string, error)) (string, error) { + return *token, nil + }, + ClassifyToolResult: classify, + } +} + +func TestCrossPlatformCoverageRunnerRetriesEditionAuthMarkerOnce(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected")) + edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error { + if expired, _ := content["expired"].(bool); expired { + return &authretry.AuthRefreshRequired{Cause: rejection} + } + return nil + })) + + var callTokens []string + runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) { + callTokens = append(callTokens, client.AuthToken) + if len(callTokens) == 1 { + return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil + } + return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(_ context.Context, gotDir, rejected string) (string, error) { + refreshCalls++ + if gotDir != configDir || rejected != "old-access" { + t.Fatalf("refresh input = dir %q token %q", gotDir, rejected) + } + token = "new-access" + return token, nil + } + + result, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if err != nil { + t.Fatal(err) + } + if refreshCalls != 1 || len(callTokens) != 2 || callTokens[0] != "old-access" || callTokens[1] != "new-access" { + t.Fatalf("refreshes=%d call tokens=%v", refreshCalls, callTokens) + } + content, _ := result.Response["content"].(map[string]any) + if content["value"] != "ok" || content["success"] != true { + t.Fatalf("result content = %#v", content) + } +} + +func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog(t *testing.T) { + installAuthRefreshRunnerSeams(t) + t.Setenv("DWS_DEBUG_AUTH", "1") + configDir := t.TempDir() + token := "old-access" + rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected")) + edition.Override(authRefreshTokenHooks(configDir, &token, func(map[string]any) error { + return &authretry.AuthRefreshRequired{Cause: rejection} + })) + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil + } + refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"uid-secret-value"}`) + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + return "", refreshErr + } + + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, rejection) || !errors.Is(err, refreshErr) { + t.Fatalf("error = %v, want rejection and refresh causes", err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth || typed.Reason != "auth_refresh_failed" || typed.Operation != "auth/token/refresh" { + t.Fatalf("refresh envelope = %#v", typed) + } + var rendered bytes.Buffer + if printErr := apperrors.PrintJSON(&rendered, err); printErr != nil { + t.Fatal(printErr) + } + for _, want := range []string{`"category": "auth"`, `"reason": "auth_refresh_failed"`, `"operation": "auth/token/refresh"`} { + if !strings.Contains(rendered.String(), want) { + t.Fatalf("structured stderr missing %s: %s", want, rendered.String()) + } + } + for _, secret := range []string{"access-token-secret", "refresh-token-secret", "uid-secret-value"} { + if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) { + t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String()) + } + } + for _, want := range []string{"auth.runtime.refresh.failed", "auth.runtime.refresh.failed.detail", "force_refresh_rejected_token", "error_type"} { + if !strings.Contains(logs.String(), want) { + t.Fatalf("safe refresh log missing %q: %s", want, logs.String()) + } + } +} + +func TestCrossPlatformCoverageRunnerSecondEditionMarkerReturnsSecondCause(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + firstCause := errors.New("first rejection") + secondCause := errors.New("second rejection") + edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error { + attempt, _ := content["attempt"].(int) + if attempt == 1 { + return &authretry.AuthRefreshRequired{Cause: firstCause} + } + return &authretry.AuthRefreshRequired{Cause: secondCause} + })) + calls := 0 + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + calls++ + return transport.ToolCallResult{Content: map[string]any{"attempt": calls}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + token = "new-access" + return token, nil + } + + _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, secondCause) || errors.Is(err, firstCause) { + t.Fatalf("error = %v, want only second rejection cause", err) + } + if calls != 2 || refreshCalls != 1 { + t.Fatalf("calls=%d refreshes=%d", calls, refreshCalls) + } +} + +func TestCrossPlatformCoverageRunnerOnAuthErrorOnlyRetriesExactUnauthorized(t *testing.T) { + t.Run("http 401 marker retries once", func(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + rejection := errors.New("transport rejected token") + hookCalls := 0 + hooks := authRefreshTokenHooks(configDir, &token, nil) + hooks.OnAuthError = func(string, error) error { + hookCalls++ + return &authretry.AuthRefreshRequired{Cause: rejection} + } + edition.Override(hooks) + calls := 0 + var callTokens []string + runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) { + calls++ + callTokens = append(callTokens, client.AuthToken) + if calls == 1 { + return transport.ToolCallResult{}, apperrors.NewAuth("unauthorized", apperrors.WithReason("http_401")) + } + return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + token = "new-access" + return token, nil + } + + if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil { + t.Fatal(err) + } + if hookCalls != 1 || refreshCalls != 1 || calls != 2 || strings.Join(callTokens, ",") != "old-access,new-access" { + t.Fatalf("hook=%d refresh=%d calls=%d tokens=%v", hookCalls, refreshCalls, calls, callTokens) + } + }) + + for _, tc := range []struct { + name string + err error + }{ + {name: "http 403", err: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"))}, + {name: "ordinary auth", err: apperrors.NewAuth("load failed", apperrors.WithReason("auth_load_failed"))}, + } { + t.Run(tc.name+" does not enter hook", func(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + hookCalls := 0 + hooks := authRefreshTokenHooks(configDir, &token, nil) + hooks.OnAuthError = func(string, error) error { + hookCalls++ + return &authretry.AuthRefreshRequired{Cause: errors.New("must not run")} + } + edition.Override(hooks) + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{}, tc.err + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + return "", nil + } + + _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, tc.err) || hookCalls != 0 || refreshCalls != 0 { + t.Fatalf("error=%v hook=%d refresh=%d", err, hookCalls, refreshCalls) + } + }) + } +} + +func TestCrossPlatformCoverageRunnerDoesNotRefreshExplicitTokenMarker(t *testing.T) { + installAuthRefreshRunnerSeams(t) + rejection := errors.New("explicit token rejected") + edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { + return &authretry.AuthRefreshRequired{Cause: rejection} + }}) + calls := 0 + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + calls++ + return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + return "", nil + } + + _, err := authRefreshTestRunner(&GlobalFlags{Token: "explicit-token"}).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, rejection) || calls != 1 || refreshCalls != 0 { + t.Fatalf("error=%v calls=%d refresh=%d", err, calls, refreshCalls) + } +} + +func TestCrossPlatformCoverageRunnerRetriesPreflightEditionMarkerOnce(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + rejection := errors.New("preflight token rejected") + edition.Override(authRefreshTokenHooks(configDir, &token, nil)) + preflightCalls := 0 + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + preflightCalls++ + if preflightCalls == 1 { + return &authretry.AuthRefreshRequired{Cause: rejection} + } + return nil + } + toolCalls := 0 + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + toolCalls++ + return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + token = "new-access" + return token, nil + } + + if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil { + t.Fatal(err) + } + if preflightCalls != 2 || toolCalls != 1 || refreshCalls != 1 { + t.Fatalf("preflights=%d tools=%d refreshes=%d", preflightCalls, toolCalls, refreshCalls) + } +} diff --git a/internal/app/auth_registry.go b/internal/app/auth_registry.go index 7c79d106..1083bd5b 100644 --- a/internal/app/auth_registry.go +++ b/internal/app/auth_registry.go @@ -49,6 +49,15 @@ func RegisterPluginAuth(productID string, auth *PluginAuth) { pluginAuthRegistry[productID] = auth } +// ClearPluginAuth removes credentials for a plugin product. Registration uses +// this before applying an accepted descriptor so a descriptor without custom +// auth cannot inherit stale credentials from an earlier root construction. +func ClearPluginAuth(productID string) { + pluginAuthMu.Lock() + defer pluginAuthMu.Unlock() + delete(pluginAuthRegistry, productID) +} + // LookupPluginAuth returns the authentication credentials registered // for the given product ID, or nil if none exists. func LookupPluginAuth(productID string) (*PluginAuth, bool) { diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go index b35c52da..75aa593c 100644 --- a/internal/app/coverage_edges_test.go +++ b/internal/app/coverage_edges_test.go @@ -554,7 +554,7 @@ func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) { defer server.Close() SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}}) t.Cleanup(func() { SetDynamicServers(nil) }) - runtime := &recoveryRuntime{transport: transport.NewClient(server.Client())} + runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}} got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"}) if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 { t.Fatalf("recovery search = %#v %v", got, err) @@ -1469,10 +1469,10 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) { if !ok { t.Fatal("mention definition missing") } - if err := renderPersonalSchema(io.Discard, def, ""); err != nil { + if err := renderPersonalSchema(io.Discard, def, "", false); err != nil { t.Fatal(err) } - if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil { + if err := renderPersonalSchema(io.Discard, def, "yaml", true); err == nil { t.Fatal("unsupported schema format succeeded") } for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} { @@ -1650,6 +1650,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T } func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T) { + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{ AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour), CorpID: "corp", UserID: "user", ClientID: "client", diff --git a/internal/app/coverage_more_test.go b/internal/app/coverage_more_test.go index 5e314abf..78f4c485 100644 --- a/internal/app/coverage_more_test.go +++ b/internal/app/coverage_more_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -164,7 +165,7 @@ func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) { } newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} } missing := t.TempDir() - if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" { + if got, err := resolveAccessTokenFromDir(context.Background(), missing); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) { t.Fatalf("missing access token = %q, %v", got, err) } if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil { diff --git a/internal/app/event_command.go b/internal/app/event_command.go index 0eff56d1..59618d4e 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -56,6 +56,7 @@ var ( eventNewEventSource = newEventSource eventNewDingtalkSource = source.New eventResolveAccessToken = ResolveAuxiliaryAccessToken + eventForceRefreshRejected = forceRefreshRejectedAccessToken eventBusRun = bus.Run eventReadyFDFromEnv = busctl.ReadyFDFromEnv eventResolvePersonal = resolvePersonalEventIdentity @@ -111,6 +112,7 @@ func newEventConsumeCommand() *cobra.Command { dryRun bool foreground bool asIdentity string + flatten bool personalOpts personalConsumeOptions streamOpts eventStreamTicketOptions ) @@ -126,7 +128,11 @@ func newEventConsumeCommand() *cobra.Command { json 每事件多行美化 JSON(必须配 --max-events 或 --duration) pretty 同 json,未来加颜色 raw 仅 SDK 原始 payload,无外层封装 - compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好) + compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor + +数据结构: + ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers) + --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费 默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加 --profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用 @@ -143,6 +149,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 } if as == "user" { personalOpts.EventKey = firstArg(args) + personalOpts.Flatten = flatten personalOpts.Common = commonConsumeOptions{ EventTypes: eventTypes, Filter: filter, @@ -166,6 +173,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user") } if err := rejectChangedFlags(c, "user", + "flatten", "subscribe-id", "rule", "name", @@ -279,6 +287,8 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)") f.StringVarP(&formatRaw, "format", "f", "ndjson", "输出格式 (ndjson/json/pretty/raw/compact);事件流默认 ndjson") + f.BoolVar(&flatten, "flatten", false, + "将个人事件 transport envelope 投影为稳定的顶层业务字段") f.StringVar(&outputDir, "output-dir", "", "每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥") f.StringArrayVar(&routesRaw, "route", nil, @@ -413,7 +423,7 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string { return "portal-ticket-normal:" + sourceID } -func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) { +func newEventSource(_ context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) { if !streamOpts.enabled() { return eventNewDingtalkSource(source.Config{ ClientID: clientID, @@ -421,14 +431,6 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin }) } - token, err := eventResolveAccessToken(ctx, configDir, "") - if err != nil { - return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err) - } - if strings.TrimSpace(token) == "" { - return nil, errors.New("event stream ticket: empty user token") - } - portalClientID := clientID portalClientSecret := clientSecret if streamOpts.usesPortalNormalMode() { @@ -440,8 +442,13 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin ClientID: portalClientID, ClientSecret: portalClientSecret, PortalTicket: &source.PortalTicketConfig{ - TicketURL: eventStreamTicketURL(streamOpts.TicketURL), - AccessToken: token, + TicketURL: eventStreamTicketURL(streamOpts.TicketURL), + AccessTokenProvider: func(ctx context.Context) (string, error) { + return eventResolveAccessToken(ctx, configDir, "") + }, + ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) { + return eventForceRefreshRejected(ctx, configDir, rejectedToken) + }, SourceID: eventStreamSourceID(streamOpts.SourceID), Mode: streamOpts.Mode, ClientID: portalClientID, diff --git a/internal/app/event_command_full_coverage_test.go b/internal/app/event_command_full_coverage_test.go index 5dc63ce8..ef271889 100644 --- a/internal/app/event_command_full_coverage_test.go +++ b/internal/app/event_command_full_coverage_test.go @@ -132,14 +132,18 @@ func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) { if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil { t.Fatal(err) } - eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail } stream := eventStreamTicketOptions{Mode: "custom"} - if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) { - t.Fatalf("stream token error = %v", err) + var captured source.Config + eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) { + captured = cfg + return &source.DingtalkSource{}, nil } - eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil } - if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil { - t.Fatal("empty stream token succeeded") + eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail } + if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err != nil { + t.Fatalf("stream source construction = %v", err) + } + if _, err := captured.PortalTicket.AccessTokenProvider(context.Background()); !errors.Is(err, fail) { + t.Fatalf("stream token provider error = %v", err) } eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil } for _, mode := range []string{"custom", "normal"} { diff --git a/internal/app/event_force_refresh_wire_test.go b/internal/app/event_force_refresh_wire_test.go new file mode 100644 index 00000000..0750b2e1 --- /dev/null +++ b/internal/app/event_force_refresh_wire_test.go @@ -0,0 +1,47 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" +) + +// TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken asserts the portal ticket +// source receives a ForceRefreshToken callback that forwards the actual +// rejected token into the app-level compare-and-refresh chain. +func TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken(t *testing.T) { + oldNew, oldRefresh := eventNewDingtalkSource, eventForceRefreshRejected + t.Cleanup(func() { eventNewDingtalkSource, eventForceRefreshRejected = oldNew, oldRefresh }) + + var captured source.Config + eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) { + captured = cfg + return &source.DingtalkSource{}, nil + } + var gotDir, gotRejected string + eventForceRefreshRejected = func(_ context.Context, configDir, rejectedToken string) (string, error) { + gotDir, gotRejected = configDir, rejectedToken + return "fresh", nil + } + if _, err := newEventSource(context.Background(), "config-dir", "client", "secret", eventStreamTicketOptions{Mode: "custom"}); err != nil { + t.Fatal(err) + } + if captured.PortalTicket == nil || captured.PortalTicket.ForceRefreshToken == nil { + t.Fatal("ForceRefreshToken not wired into portal ticket config") + } + tok, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "rejected-token") + if err != nil || tok != "fresh" { + t.Fatalf("force refresh = %q, %v", tok, err) + } + if gotDir != "config-dir" || gotRejected != "rejected-token" { + t.Fatalf("wiring passed dir %q rejected %q", gotDir, gotRejected) + } + + fail := errors.New("refresh failed") + eventForceRefreshRejected = func(context.Context, string, string) (string, error) { return "", fail } + if _, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "x"); !errors.Is(err, fail) { + t.Fatalf("refresh error = %v", err) + } +} diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 7ef4caf0..55cc9793 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -61,6 +61,7 @@ type commonConsumeOptions struct { type personalConsumeOptions struct { Common commonConsumeOptions EventKey string + Flatten bool DebugRawEvents bool SubscribeID string Rule string @@ -131,6 +132,7 @@ var ( personalFindProcess = os.FindProcess personalSignalProcess = (*os.Process).Signal personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken + personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken personalLoadTokenData = authpkg.LoadTokenData personalClientID = authpkg.ClientID personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict @@ -139,6 +141,7 @@ var ( func newEventSchemaCommand() *cobra.Command { var asIdentity string var formatRaw string + var flatten bool cmd := &cobra.Command{ Use: "schema ", Short: "显示事件 schema", @@ -156,11 +159,12 @@ func newEventSchemaCommand() *cobra.Command { if !def.Public { return personal.PublicAvailabilityError(args[0]) } - return renderPersonalSchema(c.OutOrStdout(), def, formatRaw) + return renderPersonalSchema(c.OutOrStdout(), def, formatRaw, flatten) }, } cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user") cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json") + cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema") hideEventInternalFlags(cmd, "as") cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{ Name: "event_key", @@ -188,7 +192,7 @@ func runPersonalEventList(c *cobra.Command, opts personalListOptions) error { return tw.Flush() } -func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error { +func renderPersonalSchema(w io.Writer, def personal.Definition, format string, flatten bool) error { format = strings.ToLower(strings.TrimSpace(format)) if format == "" { format = "json" @@ -198,7 +202,7 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string) e } enc := json.NewEncoder(w) enc.SetIndent("", " ") - return enc.Encode(personal.BuildSchemaDocument(def)) + return enc.Encode(personal.BuildSchemaDocumentForMode(def, flatten)) } func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error { @@ -206,6 +210,19 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { return 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 err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil { + return fmt.Errorf("event consume --as user: %w", err) + } + projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten) + configDir := defaultConfigDir() identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID) if err != nil { @@ -220,16 +237,6 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro 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) - } - projector := personalEventProjector(opts.DebugRawEvents) - if opts.Common.DryRun { if strings.TrimSpace(opts.SubscribeID) == "" { if err := validatePersonalSubscriptionOptions(opts); err != nil { @@ -246,6 +253,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro Duration: opts.Common.Duration, EventKey: opts.EventKey, Format: normalised, + Flatten: opts.Flatten, OutputDir: opts.Common.OutputDir, Routes: routes, Projector: projector, @@ -259,7 +267,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro return personalConsumeRun(ctx, cfg) } - client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) + client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts) if err != nil { return fmt.Errorf("event consume --as user: %w", err) @@ -302,6 +310,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro Duration: opts.Common.Duration, EventKey: eventKey, Format: normalised, + Flatten: opts.Flatten, OutputDir: opts.Common.OutputDir, Routes: routes, Projector: projector, @@ -365,11 +374,27 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro return err } -func personalEventProjector(debugRawEvents bool) consume.Projector { +func personalEventProjector(debugRawEvents, flatten bool) consume.Projector { if debugRawEvents { return func(ev transport.Event) (any, error) { return ev, nil } } - return personal.ProjectOutput + if flatten { + return personal.ProjectOutput + } + return nil +} + +func validatePersonalEventOutputMode(flatten, debugRawEvents bool, format consume.Format) error { + if !flatten { + return nil + } + if debugRawEvents { + return fmt.Errorf("--flatten and --debug-raw-events are mutually exclusive") + } + if format == consume.FormatRaw { + return fmt.Errorf("--flatten and --format raw are mutually exclusive") + } + return nil } func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) { @@ -498,7 +523,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error if status == "" || status == "all" { status = "" } - subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{ + subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{ Status: status, EventKey: opts.EventKey, SubscribeID: opts.SubscribeID, @@ -613,7 +638,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { if err != nil { return fmt.Errorf("event stop --as user: %w", err) } - client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) + client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) for _, id := range subscribeIDs { if err := personalDeleteSubscription(client, ctx, id); err != nil { return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err) @@ -723,7 +748,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI if err != nil { return personal.Identity{}, err } - tokenData, _ := personalLoadTokenData(configDir) + tokenData, err := personalLoadTokenData(configDir) + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err) + } var corpID, userID, clientID, refreshToken string if tokenData != nil { corpID = tokenData.CorpID @@ -769,6 +797,15 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI }, nil } +func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client { + identity.AccessToken = "" + client := personal.NewClient(baseURL, identity) + client.AccessTokenProvider = func(ctx context.Context) (string, error) { + return personalResolveAuxiliaryAccessToken(ctx, configDir, "") + } + return client +} + func personalTokenSubject(kind, token string) string { token = strings.TrimSpace(token) if token == "" { @@ -817,7 +854,12 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio } _ = ctx return source.NewPersonal(source.PersonalConfig{ - AccessToken: opts.Identity.AccessToken, + AccessTokenProvider: func(ctx context.Context) (string, error) { + return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "") + }, + ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) { + return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken) + }, ClientID: clientID, ClientSecret: clientSecret, SourceID: opts.Identity.SourceID, diff --git a/internal/app/event_personal_debug_test.go b/internal/app/event_personal_debug_test.go index 8ccf7b12..e27429fa 100644 --- a/internal/app/event_personal_debug_test.go +++ b/internal/app/event_personal_debug_test.go @@ -20,6 +20,7 @@ import ( "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/transport" + "github.com/spf13/cobra" ) func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) { @@ -52,11 +53,14 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) { } } -func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) { - if personalEventProjector(false) == nil { - t.Fatal("normal personal consume projector = nil") +func TestPersonalEventProjectorSelectsExplicitModes(t *testing.T) { + if personalEventProjector(false, false) != nil { + t.Fatal("default personal consume should preserve transport envelope") } - projector := personalEventProjector(true) + if personalEventProjector(false, true) == nil { + t.Fatal("flatten personal consume projector = nil") + } + projector := personalEventProjector(true, false) if projector == nil { t.Fatal("debug raw personal consume projector = nil") } @@ -74,6 +78,69 @@ func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) { } } +func TestEventConsumeFlattenRejectsRawModesBeforeIdentityResolution(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + { + name: "raw format", + args: []string{personal.EventMention, "--flatten", "--format", "raw"}, + want: "--flatten and --format raw are mutually exclusive", + }, + { + name: "raw debug", + args: []string{personal.EventMention, "--flatten", "--debug-raw-events"}, + want: "--flatten and --debug-raw-events are mutually exclusive", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + cmd := newEventConsumeCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(tc.args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Execute() error = %v, want %q", err, tc.want) + } + if strings.Contains(err.Error(), "login") || strings.Contains(err.Error(), "token") { + t.Fatalf("output-mode validation ran after identity resolution: %v", err) + } + }) + } +} + +func TestValidatePersonalEventOutputModeAllowsFlattenStructuredFormats(t *testing.T) { + for _, format := range []consume.Format{consume.FormatNDJSON, consume.FormatJSON, consume.FormatPretty, consume.FormatCompact} { + if err := validatePersonalEventOutputMode(true, false, format); err != nil { + t.Fatalf("validatePersonalEventOutputMode(true, false, %q) error = %v", format, err) + } + } +} + +func TestEventConsumeFlattenFlagIsForwarded(t *testing.T) { + oldRun := eventRunPersonalConsume + t.Cleanup(func() { eventRunPersonalConsume = oldRun }) + + var got personalConsumeOptions + eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error { + got = opts + return nil + } + cmd := newEventConsumeCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{personal.EventMention, "--flatten", "--format", "compact"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !got.Flatten || got.Common.FormatRaw != "compact" { + t.Fatalf("forwarded options = %#v", got) + } +} + func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) { cmd := newEventConsumeCommand() cmd.SilenceUsage = true diff --git a/internal/app/event_personal_force_refresh_wire_test.go b/internal/app/event_personal_force_refresh_wire_test.go new file mode 100644 index 00000000..0e663a91 --- /dev/null +++ b/internal/app/event_personal_force_refresh_wire_test.go @@ -0,0 +1,54 @@ +package app + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" +) + +// TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken asserts the +// personal stream source receives a ForceRefreshToken callback that forwards +// the rejected token into the app-level compare-and-refresh chain. +func TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken(t *testing.T) { + oldAux := personalResolveAuxiliaryAccessToken + oldRefresh := personalForceRefreshRejectedToken + t.Cleanup(func() { + personalResolveAuxiliaryAccessToken = oldAux + personalForceRefreshRejectedToken = oldRefresh + }) + personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { + return "old-token", nil + } + refreshErr := errors.New("refresh rejected") + var gotDir, gotRejected string + personalForceRefreshRejectedToken = func(_ context.Context, configDir, rejectedToken string) (string, error) { + gotDir, gotRejected = configDir, rejectedToken + return "", refreshErr + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ + ConfigDir: "config-dir", + Identity: personal.Identity{ClientID: "client", SourceID: "source"}, + TicketURL: srv.URL, + }) + if err != nil { + t.Fatal(err) + } + // The 401 ticket response routes the rejected token through the wired + // ForceRefreshToken; the unknown refresh failure stays fatal. + if err := src.Start(context.Background(), func(*dwsevent.RawEvent) {}); !errors.Is(err, refreshErr) { + t.Fatalf("Start() error = %v, want wrapped refresh error", err) + } + if gotDir != "config-dir" || gotRejected != "old-token" { + t.Fatalf("refresh wiring got dir %q rejected %q", gotDir, gotRejected) + } +} diff --git a/internal/app/event_personal_output_test.go b/internal/app/event_personal_output_test.go index f1d7af51..2d30c31e 100644 --- a/internal/app/event_personal_output_test.go +++ b/internal/app/event_personal_output_test.go @@ -188,7 +188,44 @@ func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) { } } -func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) { +func TestPersonalEventSchemaDefaultsToTransportEnvelope(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["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"]) + } + for _, field := range []string{"type", "seq", "event_type", "data", "headers", "subscribe_id"} { + if _, ok := props[field]; !ok { + t.Fatalf("default envelope schema missing %q: %#v", field, props) + } + } + for _, field := range []string{"content", "sender", "conversation_id", "timestamp"} { + if _, ok := props[field]; ok { + t.Fatalf("default envelope schema unexpectedly contains flat field %q", field) + } + } +} + +func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) { for _, eventKey := range []string{ personal.EventMention, personal.EventSingleChat, @@ -200,7 +237,7 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) { cmd.SilenceErrors = true var out bytes.Buffer cmd.SetOut(&out) - cmd.SetArgs([]string{eventKey}) + cmd.SetArgs([]string{eventKey, "--flatten"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v", err) } @@ -316,7 +353,7 @@ func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) { cmd.SilenceErrors = true var out bytes.Buffer cmd.SetOut(&out) - cmd.SetArgs([]string{eventKey}) + cmd.SetArgs([]string{eventKey, "--flatten"}) if err := cmd.Execute(); err != nil { t.Fatal(err) } @@ -367,6 +404,9 @@ func TestEventSchemaDefaultsToUser(t *testing.T) { if doc["event_key"] != personal.EventSingleChat { t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat) } + if doc["jq_root_path"] != ".data | fromjson" { + t.Fatalf("jq_root_path = %#v, want default envelope path", doc["jq_root_path"]) + } } func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) { @@ -469,6 +509,9 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) { if _, ok := params["odid"]; ok { t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params) } + if _, ok := params["flatten"]; !ok { + t.Fatalf("schema parameters missing flatten: %#v", params) + } for _, name := range []string{"user", "open-dingtalk-id", "group"} { param, ok := params[name].(map[string]any) if !ok { diff --git a/internal/app/force_refresh.go b/internal/app/force_refresh.go index e12cf8fd..83bae8a2 100644 --- a/internal/app/force_refresh.go +++ b/internal/app/force_refresh.go @@ -27,9 +27,13 @@ type accessTokenGetter interface { GetAccessToken(context.Context) (string, error) } +type rejectedAccessTokenRefresher interface { + ForceRefreshRejectedToken(context.Context, string) (string, error) +} + var ( - markAccessTokenStale = authpkg.MarkAccessTokenStale - newRefreshProvider = func(configDir string) accessTokenGetter { + loadRefreshTokenData = authpkg.LoadTokenData + newRefreshProvider = func(configDir string) rejectedAccessTokenRefresher { disc := slog.New(slog.NewTextHandler(io.Discard, nil)) provider := authpkg.NewOAuthProvider(configDir, disc) configureOAuthProviderCompatibility(provider, configDir) @@ -42,26 +46,33 @@ var ( // server-side rejection (HTTP 401 or business code such as // TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token. // -// Steps: -// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so -// OAuthProvider.GetAccessToken's fast-path will miss. -// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the -// existing dual-layer lock (process + file) to serialize concurrent -// refresh attempts across goroutines and processes. -// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the -// next resolveAuthToken call re-reads from disk. -// -// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper -// is the only entry point that orchestrates "force refresh" semantics. +// It snapshots the current access token, then delegates to the OAuth +// provider's dual-locked compare-and-refresh operation. If another caller has +// already rotated the token, that newer token is reused without another +// refresh request. func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) { if strings.TrimSpace(configDir) == "" { return "", fmt.Errorf("config directory is empty") } - if err := markAccessTokenStale(configDir); err != nil { - return "", fmt.Errorf("mark access token stale: %w", err) + data, err := loadRefreshTokenData(configDir) + if err != nil { + return "", err + } + if data == nil || strings.TrimSpace(data.AccessToken) == "" { + return "", fmt.Errorf("stored access token is empty") + } + return forceRefreshRejectedAccessToken(ctx, configDir, data.AccessToken) +} + +func forceRefreshRejectedAccessToken(ctx context.Context, configDir, rejectedAccessToken string) (string, error) { + if strings.TrimSpace(configDir) == "" { + return "", fmt.Errorf("config directory is empty") + } + if strings.TrimSpace(rejectedAccessToken) == "" { + return "", fmt.Errorf("rejected access token is empty") } provider := newRefreshProvider(configDir) - tok, err := provider.GetAccessToken(ctx) + tok, err := provider.ForceRefreshRejectedToken(ctx, rejectedAccessToken) if err != nil { return "", err } diff --git a/internal/app/legacy.go b/internal/app/legacy.go index 3eaab247..0b1f72df 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -27,21 +27,27 @@ import ( "github.com/spf13/cobra" ) -func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command { +func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command { injectStaticServers() helpers.InitDeps(caller) commands := helpers.NewPublicCommands(runner) // Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the // command tree, so distilled high-frequency operations mount alongside the // built-ins. Conflicts with built-ins are skipped inside Load. - if _, err := userdef.Load(); err != nil { - slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err) + if loadUserShortcuts { + if _, err := userdef.Load(); err != nil { + slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err) + } } // Built-in + user shortcuts (`dws +`) share the same // command tree; mergeTopLevelCommands folds each shortcut's service parent // into the matching helper command so the `+leaf` sits alongside existing // subcommands. - commands = append(commands, builtin.Commands()...) + if loadUserShortcuts { + commands = append(commands, builtin.Commands()...) + } else { + commands = append(commands, builtin.BaseCommands()...) + } return mergeTopLevelCommands(commands) } diff --git a/internal/app/mcp_url_command.go b/internal/app/mcp_url_command.go new file mode 100644 index 00000000..00333f96 --- /dev/null +++ b/internal/app/mcp_url_command.go @@ -0,0 +1,108 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +const ( + mcpMetaServerID = "mcp-meta" + mcpMetaURLTool = "get_mcp_server_url" +) + +func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command { + group := &cobra.Command{ + Use: "url", + Short: "管理 MCP 服务连接地址", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + group.AddCommand(newMCPURLGetCommand(caller)) + return group +} + +func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址", + Long: "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 " + + "Streamable HTTP 服务地址。\n\n" + + "安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用," + + "请勿分享到群聊、文档、邮件、代码仓库或日志。", + Example: " dws mcp url get 2480\n" + + " dws mcp url get 2480 --format json", + Args: cobra.ExactArgs(1), + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if caller == nil { + return fmt.Errorf("MCP tool caller is not configured") + } + mcpID := strings.TrimSpace(args[0]) + if mcpID == "" { + return fmt.Errorf("mcpId 不能为空") + } + + result, err := caller.CallTool(cmd.Context(), mcpMetaServerID, mcpMetaURLTool, map[string]any{ + "mcpId": mcpID, + }) + if err != nil { + return fmt.Errorf("获取 MCP 服务地址: %w", err) + } + return writeMCPURLResult(cmd, result) + }, + } + cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{ + Name: "mcp_id", + Type: "string", + Description: "钉钉 MCP 市场中的 mcpId", + Required: true, + Index: 0, + }) + return cmd +} + +func writeMCPURLResult(cmd *cobra.Command, result *edition.ToolResult) error { + if result == nil { + return fmt.Errorf("MCP 元服务返回空结果") + } + // get_mcp_server_url returns one JSON document in its first non-empty text + // block. Other block types and trailing blocks are intentionally ignored. + for _, block := range result.Content { + if block.Type != "text" || strings.TrimSpace(block.Text) == "" { + continue + } + if err := apperrors.ClassifyMCPResponseText(block.Text); err != nil { + return err + } + + var payload any + if err := json.Unmarshal([]byte(block.Text), &payload); err != nil { + return fmt.Errorf("MCP 元服务返回了无效 JSON: %w", err) + } + return output.WriteCommandPayload(cmd, payload, output.FormatJSON) + } + return fmt.Errorf("MCP 元服务返回空结果") +} diff --git a/internal/app/mcp_url_command_test.go b/internal/app/mcp_url_command_test.go new file mode 100644 index 00000000..c8c8ae0e --- /dev/null +++ b/internal/app/mcp_url_command_test.go @@ -0,0 +1,194 @@ +// 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" + "errors" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type mcpURLTestCaller struct { + productID string + toolName string + args map[string]any + result *edition.ToolResult + err error +} + +func (c *mcpURLTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.productID = productID + c.toolName = toolName + c.args = args + return c.result, c.err +} + +func (*mcpURLTestCaller) Format() string { return "json" } +func (*mcpURLTestCaller) DryRun() bool { return false } +func (*mcpURLTestCaller) Fields() string { return "" } +func (*mcpURLTestCaller) JQ() string { return "" } + +func executeMCPURLCommand(t *testing.T, caller edition.ToolCaller, args ...string) (string, error) { + t.Helper() + root := &cobra.Command{Use: "mcp", SilenceErrors: true, SilenceUsage: true} + root.AddCommand(newMCPURLGroup(caller)) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + err := root.ExecuteContext(t.Context()) + return out.String(), err +} + +func TestMCPURLGetCallsMetaServerAndPreservesResponse(t *testing.T) { + const response = `{"result":{"mcpURL":"https://example.test/mcp?key=one&token=two","mcpJSON":{"transport":"streamable-http"},"name":"Example"}}` + caller := &mcpURLTestCaller{ + result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, + } + + out, err := executeMCPURLCommand(t, caller, "url", "get", " 10043 ") + if err != nil { + t.Fatalf("execute mcp url get: %v", err) + } + if caller.productID != mcpMetaServerID { + t.Fatalf("productID = %q, want %q", caller.productID, mcpMetaServerID) + } + if caller.toolName != mcpMetaURLTool { + t.Fatalf("toolName = %q, want %q", caller.toolName, mcpMetaURLTool) + } + if got := caller.args["mcpId"]; got != "10043" { + t.Fatalf("mcpId = %#v, want %q", got, "10043") + } + + var payload map[string]any + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out) + } + result, ok := payload["result"].(map[string]any) + if !ok { + t.Fatalf("output result = %#v", payload["result"]) + } + if got := result["mcpURL"]; got != "https://example.test/mcp?key=one&token=two" { + t.Fatalf("result.mcpURL = %#v", got) + } +} + +func TestMCPURLGetRejectsBlankID(t *testing.T) { + _, err := executeMCPURLCommand(t, &mcpURLTestCaller{}, "url", "get", " ") + if err == nil || !strings.Contains(err.Error(), "mcpId 不能为空") { + t.Fatalf("error = %v, want blank mcpId error", err) + } +} + +func TestMCPURLGroupShowsHelp(t *testing.T) { + out, err := executeMCPURLCommand(t, nil, "url") + if err != nil { + t.Fatalf("execute mcp url: %v", err) + } + if !strings.Contains(out, "get") { + t.Fatalf("help output does not list get command:\n%s", out) + } +} + +func TestMCPURLGetRejectsMissingCaller(t *testing.T) { + _, err := executeMCPURLCommand(t, nil, "url", "get", "10043") + if err == nil || !strings.Contains(err.Error(), "caller is not configured") { + t.Fatalf("error = %v, want missing caller error", err) + } +} + +func TestMCPURLGetPropagatesCallError(t *testing.T) { + caller := &mcpURLTestCaller{err: errors.New("permission denied")} + _, err := executeMCPURLCommand(t, caller, "url", "get", "10043") + if err == nil || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("error = %v, want call error", err) + } +} + +func TestMCPURLGetRejectsInvalidJSON(t *testing.T) { + caller := &mcpURLTestCaller{ + result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "not-json"}}}, + } + _, err := executeMCPURLCommand(t, caller, "url", "get", "10043") + if err == nil || !strings.Contains(err.Error(), "无效 JSON") { + t.Fatalf("error = %v, want invalid JSON error", err) + } +} + +func TestMCPURLGetRejectsEmptyResults(t *testing.T) { + tests := []struct { + name string + result *edition.ToolResult + }{ + {name: "nil result"}, + { + name: "no usable text content", + result: &edition.ToolResult{Content: []edition.ContentBlock{ + {Type: "image", Text: "ignored"}, + {Type: "text", Text: " "}, + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &mcpURLTestCaller{result: tt.result} + _, err := executeMCPURLCommand(t, caller, "url", "get", "10043") + if err == nil || !strings.Contains(err.Error(), "返回空结果") { + t.Fatalf("error = %v, want empty result error", err) + } + }) + } +} + +func TestMCPURLGetClassifiesBusinessError(t *testing.T) { + caller := &mcpURLTestCaller{ + result: &edition.ToolResult{Content: []edition.ContentBlock{{ + Type: "text", + Text: `{"success":false,"errorMsg":"搜索内容不能为空"}`, + }}}, + } + _, err := executeMCPURLCommand(t, caller, "url", "get", "10043") + if err == nil { + t.Fatal("expected classified business error") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "business_error" { + t.Fatalf("error = %#v, want classified business error", err) + } +} + +func TestRootRegistersMCPURLGet(t *testing.T) { + root := NewRootCommand(t.Context()) + mcp, _, err := root.Find([]string{"mcp"}) + if err != nil { + t.Fatalf("find mcp: %v", err) + } + if mcp.Hidden { + t.Fatal("mcp command must be public when it contains reviewed public helpers") + } + cmd, _, err := root.Find([]string{"mcp", "url", "get"}) + if err != nil { + t.Fatalf("find mcp url get: %v", err) + } + if got := cmd.CommandPath(); got != "dws mcp url get" { + t.Fatalf("command path = %q, want %q", got, "dws mcp url get") + } +} diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index dd3cffad..cff16e16 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -56,7 +56,7 @@ var openBrowserFunc = tryOpenBrowser var ( patAuthorizationTimeout = PatAuthRetryTimeout patAuthorizationPollInterval = PatAuthPollInterval - patLoadTokenData = authpkg.LoadTokenData + patResolveAccessToken = ResolveAuxiliaryAccessToken patWaitForAuthorization = WaitForPatAuthorization patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval patSaveAppConfig = authpkg.SaveAppConfig @@ -272,7 +272,7 @@ func patAuthorizationURIFromData(data map[string]any) string { // WaitForPatAuthorization polls until the user completes authorization or timeout. // It returns true if authorization was completed, false if timed out or cancelled. -func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool { +func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) (bool, error) { timeout := patAuthorizationTimeout deadline := time.Now().Add(timeout) pollTicker := time.NewTicker(patAuthorizationPollInterval) @@ -290,27 +290,26 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr select { case <-ctx.Done(): fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error")) - return false + return false, ctx.Err() case <-time.After(time.Until(deadline)): fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout) fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ")) - return false + return false, nil case <-pollTicker.C: pollCount++ elapsed := time.Since(start).Truncate(time.Second) remaining := time.Until(deadline).Truncate(time.Second) - // Check if token is now valid - tokenData, err := patLoadTokenData(configDir) - if err == nil && tokenData != nil { - if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() { - fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", - tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining) - fmt.Fprintln(output) - return true - } + // Check the same resolver used by every outbound bearer request. + if _, err := patResolveAccessToken(ctx, configDir, ""); err == nil { + fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", + tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining) + fmt.Fprintln(output) + return true, nil + } else if !stderrors.Is(err, authpkg.ErrTokenDataNotFound) { + return false, fmt.Errorf("check authorization token: %w", err) } // Show polling status @@ -340,7 +339,10 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati PrintPatAuthError(output, scopeErr) // Wait for user to complete authorization - authorized := patWaitForAuthorization(ctx, configDir, output) + authorized, waitErr := patWaitForAuthorization(ctx, configDir, output) + if waitErr != nil { + return executor.Result{}, waitErr + } if !authorized { return executor.Result{}, apperrors.NewAuth( "等待用户授权超时", @@ -794,12 +796,6 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir pollURL := fmt.Sprintf("%s%s?flowId=%s", authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID)) - // Load user access token for the poll request header. - var accessToken string - if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil { - accessToken = tokenData.AccessToken - } - // Use a client that does NOT follow redirects, so we can detect SSO 302. noRedirectClient := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -828,6 +824,10 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir slog.Debug("PAT poll: failed to create request", "error", err) continue } + accessToken, tokenErr := patResolveAccessToken(ctx, configDir, "") + if tokenErr != nil && !stderrors.Is(tokenErr, authpkg.ErrTokenDataNotFound) { + return "", "", fmt.Errorf("resolve PAT poll access token: %w", tokenErr) + } if accessToken != "" { req.Header.Set("x-user-access-token", accessToken) } diff --git a/internal/app/pat_auth_retry_full_coverage_test.go b/internal/app/pat_auth_retry_full_coverage_test.go index 6023e89e..0fbb89d3 100644 --- a/internal/app/pat_auth_retry_full_coverage_test.go +++ b/internal/app/pat_auth_retry_full_coverage_test.go @@ -52,39 +52,41 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T) oldTimeout := patAuthorizationTimeout oldInterval := patAuthorizationPollInterval - oldLoad := patLoadTokenData + oldResolve := patResolveAccessToken t.Cleanup(func() { patAuthorizationTimeout = oldTimeout patAuthorizationPollInterval = oldInterval - patLoadTokenData = oldLoad + patResolveAccessToken = oldResolve }) patAuthorizationTimeout = 50 * time.Millisecond patAuthorizationPollInterval = time.Millisecond - patLoadTokenData = func(string) (*authpkg.TokenData, error) { - return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + patResolveAccessToken = func(context.Context, string, string) (string, error) { + return "token", nil } out.Reset() - if !WaitForPatAuthorization(context.Background(), "", &out) { + if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || !ok { t.Fatal("valid token did not authorize") } ctx, cancel := context.WithCancel(context.Background()) cancel() out.Reset() - if WaitForPatAuthorization(ctx, "", &out) { - t.Fatal("cancelled authorization succeeded") + if ok, err := WaitForPatAuthorization(ctx, "", &out); ok || !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled authorization = %v, %v", ok, err) } patAuthorizationTimeout = time.Millisecond patAuthorizationPollInterval = time.Hour out.Reset() - if WaitForPatAuthorization(context.Background(), "", &out) { - t.Fatal("timed out authorization succeeded") + if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok { + t.Fatalf("timed out authorization = %v, %v", ok, err) } patAuthorizationTimeout = 5 * time.Millisecond patAuthorizationPollInterval = time.Millisecond - patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil } + patResolveAccessToken = func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + } out.Reset() - if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") { - t.Fatalf("invalid-token polling output = %q", out.String()) + if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") { + t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String()) } } @@ -109,12 +111,12 @@ func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing. t.Setenv("DWS_CONFIG_DIR", t.TempDir()) scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"} - patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false } + patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return false, nil } if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil { t.Fatal("PAT retry timeout succeeded") } wantErr := errors.New("runner failed") - patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true } + patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return true, nil } if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) { t.Fatalf("authorized retry = %v", err) } @@ -215,15 +217,15 @@ func patRaw(flowID, clientID, secret string) string { func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) { oldDo := patPollHTTPDo oldRequest := patPollNewRequest - oldLoad := patLoadTokenData + oldResolve := patResolveAccessToken oldBrowser := patBrowserOpenCommand t.Cleanup(func() { patPollHTTPDo = oldDo patPollNewRequest = oldRequest - patLoadTokenData = oldLoad + patResolveAccessToken = oldResolve patBrowserOpenCommand = oldBrowser }) - patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil } + patResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil } cancelled, cancelNow := context.WithCancel(context.Background()) cancelNow() if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled { diff --git a/internal/app/plugin_commands.go b/internal/app/plugin_commands.go new file mode 100644 index 00000000..73b0db79 --- /dev/null +++ b/internal/app/plugin_commands.go @@ -0,0 +1,1041 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type pluginFlagKind uint8 + +const ( + pluginFlagString pluginFlagKind = iota + pluginFlagInt + pluginFlagFloat + pluginFlagBool + pluginFlagStringSlice + pluginFlagJSON +) + +type pluginFlagBinding struct { + property string + names []string + kind pluginFlagKind + required bool + defaultProvided bool + defaultValue string + envDefault string + positional bool + positionalIndex int + omitWhen string +} + +type pluginFlagReservations struct { + names map[string]bool + shorthands map[string]bool +} + +// buildPluginCommands compiles only manifest-authored CLI overlays. It never +// starts a transport or calls tools/list, so plugin help stays available when +// a local subprocess or remote MCP endpoint is offline. +func buildPluginCommands( + servers []mcptypes.ServerDescriptor, + runner executor.Runner, + hostRoot *cobra.Command, +) []*cobra.Command { + reservations := pluginReservedFlags(hostRoot) + sorted := append([]mcptypes.ServerDescriptor(nil), servers...) + sort.SliceStable(sorted, func(i, j int) bool { + left := strings.TrimSpace(sorted[i].DisplayName) + "\x00" + strings.TrimSpace(sorted[i].Key) + right := strings.TrimSpace(sorted[j].DisplayName) + "\x00" + strings.TrimSpace(sorted[j].Key) + return left < right + }) + + roots := make(map[string]*cobra.Command) + for _, server := range sorted { + overlay := server.CLI + if overlay.Skip || len(overlay.ToolOverrides) == 0 { + continue + } + if reason := unsupportedPluginOverlay(overlay); reason != "" { + slog.Warn("plugin: unsupported overlay semantics, skipping command tree", + "plugin", server.DisplayName, "field", reason) + continue + } + name := firstNonEmptyPluginString(overlay.Command, overlay.ID, server.Key) + if !validPluginCommandName(name) { + slog.Warn("plugin: invalid overlay command name, skipping", + "plugin", server.DisplayName, "command", name) + continue + } + + root := buildPluginOverlayRoot(server, runner, reservations) + if root == nil { + continue + } + if existing := roots[name]; existing != nil { + mergePluginRoot(existing, root) + continue + } + roots[name] = root + } + + names := make([]string, 0, len(roots)) + for name := range roots { + names = append(names, name) + } + sort.Strings(names) + commands := make([]*cobra.Command, 0, len(names)) + for _, name := range names { + commands = append(commands, roots[name]) + } + return commands +} + +func buildPluginOverlayRoot( + server mcptypes.ServerDescriptor, + runner executor.Runner, + reservations pluginFlagReservations, +) *cobra.Command { + overlay := server.CLI + name := firstNonEmptyPluginString(overlay.Command, overlay.ID, server.Key) + short := firstNonEmptyPluginString(overlay.Description, server.Description, server.DisplayName, name) + root := cobracmd.NewGroupCommand(name, short) + root.Hidden = overlay.Hidden + root.Aliases = safePluginAliases(overlay.Aliases, name) + cmdutil.MarkPluginSource(root) + + groups := make(map[string]*cobra.Command) + groupNames := make([]string, 0, len(overlay.Groups)) + for groupName := range overlay.Groups { + groupNames = append(groupNames, groupName) + } + sort.Strings(groupNames) + for _, groupName := range groupNames { + group := overlay.Groups[groupName] + ensurePluginGroup(root, groupName, group.Description, groups) + } + + toolNames := make([]string, 0, len(overlay.ToolOverrides)) + for toolName := range overlay.ToolOverrides { + toolNames = append(toolNames, toolName) + } + sort.Strings(toolNames) + leafCount := 0 + for _, toolName := range toolNames { + override := overlay.ToolOverrides[toolName] + if override.Hidden || strings.TrimSpace(toolName) == "" { + continue + } + leaf := buildPluginLeaf(server, toolName, override, runner, reservations) + if leaf == nil { + continue + } + parent := root + if groupPath := strings.TrimSpace(override.Group); groupPath != "" { + parent = ensurePluginGroup(root, groupPath, groupPath, groups) + if parent == nil { + slog.Warn("plugin: invalid overlay group path, skipping leaf", + "plugin", server.DisplayName, "tool", toolName, "group", groupPath) + continue + } + } + if existing := cobracmd.ChildByName(parent, leaf.Name()); existing != nil { + slog.Warn("plugin: duplicate overlay leaf, keeping first", + "plugin", server.DisplayName, + "command", strings.TrimSpace(parent.CommandPath()+" "+leaf.Name())) + continue + } + parent.AddCommand(leaf) + leafCount++ + } + + if leafCount == 0 { + return nil + } + pruneEmptyPluginGroups(root) + return root +} + +func buildPluginLeaf( + server mcptypes.ServerDescriptor, + toolName string, + override mcptypes.CLIToolOverride, + runner executor.Runner, + reservations pluginFlagReservations, +) *cobra.Command { + overlay := server.CLI + if reason := unsupportedPluginToolOverride(override); reason != "" { + slog.Warn("plugin: unsupported tool override semantics, skipping leaf", + "plugin", server.DisplayName, "tool", toolName, "field", reason) + return nil + } + name := strings.TrimSpace(override.CLIName) + if name == "" { + name = derivePluginCommandName(toolName, overlay.Prefixes) + } + if !validPluginCommandName(name) { + slog.Warn("plugin: invalid overlay leaf name, skipping", + "plugin", server.DisplayName, "tool", toolName, "command", name) + return nil + } + + short := firstNonEmptyPluginString(override.Description, toolName) + bindings, use, argsValidator, ok := registerPluginBindings(name, override, reservations) + if !ok { + return nil + } + if !validPluginFlagConstraints(bindings, override) { + slog.Warn("plugin: invalid flag constraints, skipping leaf", + "plugin", server.DisplayName, "tool", toolName) + return nil + } + canonicalProduct := firstNonEmptyPluginString(overlay.ID, server.Key) + cmd := &cobra.Command{ + Use: use, + Short: short, + Example: strings.TrimRight(override.Example, " \t\r\n"), + Args: argsValidator, + DisableAutoGenTag: true, + } + cmdutil.MarkPluginSource(cmd) + + registerPluginFlags(cmd, bindings, override, reservations) + applyPluginFlagConstraints(cmd, override) + cmd.RunE = func(cmd *cobra.Command, args []string) error { + jsonPayload, err := cmd.Flags().GetString("json") + if err != nil { + return apperrors.NewInternal("failed to read --json") + } + paramsPayload, err := cmd.Flags().GetString("params") + if err != nil { + return apperrors.NewInternal("failed to read --params") + } + params, err := executor.MergePayloads(jsonPayload, paramsPayload, nil) + if err != nil { + return err + } + if err := collectPluginBindings(cmd, args, bindings, params); err != nil { + return err + } + if wrapper := strings.TrimSpace(override.BodyWrapper); wrapper != "" { + wrapPluginParams(params, wrapper) + } + + dryRun := pluginRootBoolFlag(cmd, "dry-run") + if override.IsSensitive && !dryRun && !pluginRootBoolFlag(cmd, "yes") { + return pluginConfirmationRequired(cmd.CommandPath()) + } + if runner == nil { + return apperrors.NewInternal("plugin command runner is not configured") + } + + invocation := executor.NewCompatibilityInvocation( + cobracmd.LegacyCommandPath(cmd), + canonicalProduct, + toolName, + params, + ) + invocation.DryRun = dryRun + result, err := runner.Run(cmd.Context(), invocation) + if err != nil { + return err + } + return output.WriteCommandPayload(cmd, result, output.FormatJSON) + } + return cmd +} + +func registerPluginBindings( + name string, + override mcptypes.CLIToolOverride, + reservations pluginFlagReservations, +) ([]pluginFlagBinding, string, cobra.PositionalArgs, bool) { + properties := make([]string, 0, len(override.Flags)) + for property := range override.Flags { + properties = append(properties, property) + } + sort.Strings(properties) + + bindings := make([]pluginFlagBinding, 0, len(properties)) + usedNames := make(map[string]bool, len(reservations.names)) + for reserved := range reservations.names { + usedNames[reserved] = true + } + usedPositions := make(map[int]bool) + maxPositionals := 0 + minPositionals := 0 + type positionalLabel struct { + index int + name string + } + var labels []positionalLabel + + for _, property := range properties { + flagOverride := override.Flags[property] + if strings.Contains(property, ".") { + slog.Warn("plugin: dotted flag properties are unsupported, skipping leaf", + "command", name, "property", property) + return nil, "", nil, false + } + if reason := unsupportedPluginFlagOverride(flagOverride); reason != "" { + slog.Warn("plugin: unsupported flag override semantics, skipping leaf", + "command", name, "property", property, "field", reason) + return nil, "", nil, false + } + primary := strings.TrimSpace(flagOverride.Alias) + if primary == "" && !flagOverride.Positional { + primary = pluginKebabName(property) + } + if !flagOverride.Positional && + (!validPluginFlagName(primary) || usedNames[primary]) { + slog.Warn("plugin: primary overlay flag conflicts with the host CLI, skipping leaf", + "command", name, "flag", primary, "property", property) + return nil, "", nil, false + } + + names := make([]string, 0, 1+len(flagOverride.Aliases)) + for _, candidate := range append([]string{primary}, flagOverride.Aliases...) { + candidate = strings.TrimSpace(candidate) + if !validPluginFlagName(candidate) || usedNames[candidate] { + if candidate != "" { + slog.Warn("plugin: duplicate or invalid overlay flag, skipping", + "command", name, "flag", candidate, "property", property) + } + continue + } + usedNames[candidate] = true + names = append(names, candidate) + } + + if flagOverride.Positional { + index := flagOverride.PositionalIndex + if index < 0 || usedPositions[index] { + slog.Warn("plugin: invalid or duplicate positional binding, skipping leaf", + "command", name, "property", property, "index", index) + return nil, "", nil, false + } + usedPositions[index] = true + if index+1 > maxPositionals { + maxPositionals = index + 1 + } + if flagOverride.Required && len(names) == 0 && index+1 > minPositionals { + minPositionals = index + 1 + } + labels = append(labels, positionalLabel{index: index, name: property}) + } + + bindings = append(bindings, pluginFlagBinding{ + property: property, + names: names, + kind: pluginFlagKindFromString(flagOverride.Type), + required: flagOverride.Required, + defaultProvided: flagOverride.Default != "", + defaultValue: flagOverride.Default, + envDefault: strings.TrimSpace(flagOverride.EnvDefault), + positional: flagOverride.Positional, + positionalIndex: flagOverride.PositionalIndex, + omitWhen: strings.ToLower(strings.TrimSpace(flagOverride.OmitWhen)), + }) + } + for index := 0; index < maxPositionals; index++ { + if !usedPositions[index] { + slog.Warn("plugin: positional bindings must use contiguous indexes, skipping leaf", + "command", name, "missingIndex", index) + return nil, "", nil, false + } + } + + use := name + sort.SliceStable(labels, func(i, j int) bool { return labels[i].index < labels[j].index }) + for _, label := range labels { + use += " [" + label.name + "]" + } + + var validator cobra.PositionalArgs = cobra.NoArgs + switch { + case maxPositionals == 0: + validator = cobra.NoArgs + case minPositionals == maxPositionals: + validator = cobra.ExactArgs(maxPositionals) + case minPositionals > 0: + validator = cobra.RangeArgs(minPositionals, maxPositionals) + default: + validator = cobra.MaximumNArgs(maxPositionals) + } + return bindings, use, validator, true +} + +func registerPluginFlags( + cmd *cobra.Command, + bindings []pluginFlagBinding, + override mcptypes.CLIToolOverride, + reservations pluginFlagReservations, +) { + usedShorthands := make(map[string]bool) + for _, binding := range bindings { + flagOverride := override.Flags[binding.property] + for index, name := range binding.names { + shorthand := "" + if index == 0 { + shorthand = safePluginShorthand( + flagOverride.Shorthand, + usedShorthands, + reservations.shorthands, + ) + } + usage := firstNonEmptyPluginString(flagOverride.Description, binding.property) + registerPluginFlag(cmd.Flags(), name, shorthand, usage, binding.kind, flagOverride.Default) + if index > 0 || flagOverride.Hidden { + _ = cmd.Flags().MarkHidden(name) + } + } + } + + cmd.Flags().String("json", "", "Base JSON object payload for this command") + cmd.Flags().String("params", "", "Additional JSON object payload merged after --json") + _ = cmd.Flags().MarkHidden("json") + _ = cmd.Flags().MarkHidden("params") +} + +func registerPluginFlag(flags *pflag.FlagSet, name, shorthand, usage string, kind pluginFlagKind, rawDefault string) { + switch kind { + case pluginFlagInt: + value, _ := strconv.Atoi(strings.TrimSpace(rawDefault)) + flags.IntP(name, shorthand, value, usage) + case pluginFlagFloat: + value, _ := strconv.ParseFloat(strings.TrimSpace(rawDefault), 64) + flags.Float64P(name, shorthand, value, usage) + case pluginFlagBool: + value, _ := strconv.ParseBool(strings.TrimSpace(rawDefault)) + flags.BoolP(name, shorthand, value, usage) + case pluginFlagStringSlice: + var value []string + if strings.TrimSpace(rawDefault) != "" { + for _, item := range strings.Split(rawDefault, ",") { + if item = strings.TrimSpace(item); item != "" { + value = append(value, item) + } + } + } + flags.StringSliceP(name, shorthand, value, usage) + default: + flags.StringP(name, shorthand, rawDefault, usage) + } +} + +func collectPluginBindings( + cmd *cobra.Command, + args []string, + bindings []pluginFlagBinding, + params map[string]any, +) error { + for _, binding := range bindings { + var ( + value any + set bool + err error + ) + for _, name := range binding.names { + if flag := cmd.Flags().Lookup(name); flag != nil && flag.Changed { + value, err = readPluginFlag(cmd.Flags(), name, binding.kind) + set = err == nil + break + } + } + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("invalid plugin flag for %s: %v", binding.property, err)) + } + if !set { + if existing, exists := params[binding.property]; exists { + value = existing + set = true + } + } + if !set && binding.positional && binding.positionalIndex >= 0 && binding.positionalIndex < len(args) { + value, err = parsePluginValue(args[binding.positionalIndex], binding.kind) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf( + "invalid positional value for %s: %v", + binding.property, + err, + )) + } + set = true + } + if !set && binding.defaultProvided { + value, err = parsePluginValue(binding.defaultValue, binding.kind) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("invalid default for %s: %v", binding.property, err)) + } + set = true + } + if !set && binding.envDefault != "" { + if raw := strings.TrimSpace(os.Getenv(binding.envDefault)); raw != "" { + value, err = parsePluginValue(raw, binding.kind) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("invalid %s value from %s: %v", binding.property, binding.envDefault, err)) + } + set = true + } + } + if !set { + if binding.required { + label := binding.property + if len(binding.names) > 0 { + label = "--" + binding.names[0] + } + return apperrors.NewValidation("missing required plugin parameter: " + label) + } + continue + } + if shouldOmitPluginValue(value, binding.omitWhen) { + delete(params, binding.property) + if binding.required { + return apperrors.NewValidation("required plugin parameter is empty: " + binding.property) + } + continue + } + params[binding.property] = value + } + return nil +} + +func readPluginFlag(flags *pflag.FlagSet, name string, kind pluginFlagKind) (any, error) { + switch kind { + case pluginFlagInt: + return flags.GetInt(name) + case pluginFlagFloat: + return flags.GetFloat64(name) + case pluginFlagBool: + return flags.GetBool(name) + case pluginFlagStringSlice: + return flags.GetStringSlice(name) + case pluginFlagJSON: + raw, err := flags.GetString(name) + if err != nil { + return nil, err + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, err + } + return value, nil + default: + return flags.GetString(name) + } +} + +func parsePluginValue(raw string, kind pluginFlagKind) (any, error) { + switch kind { + case pluginFlagInt: + return strconv.Atoi(strings.TrimSpace(raw)) + case pluginFlagFloat: + return strconv.ParseFloat(strings.TrimSpace(raw), 64) + case pluginFlagBool: + return strconv.ParseBool(strings.TrimSpace(raw)) + case pluginFlagStringSlice: + var values []string + for _, item := range strings.Split(raw, ",") { + if item = strings.TrimSpace(item); item != "" { + values = append(values, item) + } + } + return values, nil + case pluginFlagJSON: + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil, err + } + return value, nil + default: + return raw, nil + } +} + +func shouldOmitPluginValue(value any, mode string) bool { + if mode == "never" { + return false + } + switch typed := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(typed) == "" + case []string: + return len(typed) == 0 + } + if mode != "zero" { + return false + } + switch typed := value.(type) { + case bool: + return !typed + case int: + return typed == 0 + case float64: + return typed == 0 + case []any: + return len(typed) == 0 + case map[string]any: + return len(typed) == 0 + default: + return false + } +} + +func wrapPluginParams(params map[string]any, wrapper string) { + if params == nil || strings.TrimSpace(wrapper) == "" { + return + } + body := make(map[string]any) + if existing, ok := params[wrapper].(map[string]any); ok { + for key, value := range existing { + body[key] = value + } + delete(params, wrapper) + } + for key, value := range params { + if strings.HasPrefix(key, "_") { + continue + } + body[key] = value + delete(params, key) + } + params[wrapper] = body +} + +func applyPluginFlagConstraints(cmd *cobra.Command, override mcptypes.CLIToolOverride) { + validate := func(group []string) []string { + names := make([]string, 0, len(group)) + for _, name := range group { + name = strings.TrimSpace(name) + if name == "" || cmd.Flags().Lookup(name) == nil { + return nil + } + names = append(names, name) + } + return names + } + for _, group := range override.MutuallyExclusive { + if names := validate(group); len(names) > 1 { + cmd.MarkFlagsMutuallyExclusive(names...) + } + } + for _, group := range override.RequireOneOf { + if names := validate(group); len(names) > 0 { + cmd.MarkFlagsOneRequired(names...) + } + } + for _, group := range override.RequireTogether { + if names := validate(group); len(names) > 1 { + cmd.MarkFlagsRequiredTogether(names...) + } + } +} + +func validPluginFlagConstraints(bindings []pluginFlagBinding, override mcptypes.CLIToolOverride) bool { + known := make(map[string]bool) + for _, binding := range bindings { + for _, name := range binding.names { + known[name] = true + } + } + validate := func(groups [][]string, minimum int) bool { + for _, group := range groups { + if len(group) < minimum { + return false + } + seen := make(map[string]bool, len(group)) + for _, rawName := range group { + name := strings.TrimSpace(rawName) + if name == "" || !known[name] || seen[name] { + return false + } + seen[name] = true + } + } + return true + } + return validate(override.MutuallyExclusive, 2) && + validate(override.RequireOneOf, 1) && + validate(override.RequireTogether, 2) +} + +func ensurePluginGroup( + root *cobra.Command, + rawPath string, + description string, + groups map[string]*cobra.Command, +) *cobra.Command { + if !validPluginGroupPath(rawPath) { + return nil + } + parts := strings.Split(strings.TrimSpace(rawPath), ".") + parent := root + var pathParts []string + for index, rawPart := range parts { + part := strings.TrimSpace(rawPart) + pathParts = append(pathParts, part) + path := strings.Join(pathParts, ".") + if existing := groups[path]; existing != nil { + parent = existing + continue + } + short := part + if index == len(parts)-1 { + short = firstNonEmptyPluginString(description, part) + } + group := cobracmd.NewGroupCommand(part, short) + cmdutil.MarkPluginSource(group) + parent.AddCommand(group) + groups[path] = group + parent = group + } + return parent +} + +func validPluginGroupPath(rawPath string) bool { + parts := strings.Split(strings.TrimSpace(rawPath), ".") + for _, rawPart := range parts { + if !validPluginCommandName(strings.TrimSpace(rawPart)) { + return false + } + } + return true +} + +func mergePluginRoot(destination, source *cobra.Command) { + if destination == nil || source == nil { + return + } + aliases := append(append([]string(nil), destination.Aliases...), source.Aliases...) + destination.Aliases = safePluginAliases(aliases, destination.Name()) + cobracmd.MergeCommandTree(destination, source) +} + +func pruneEmptyPluginGroups(parent *cobra.Command) { + if parent == nil { + return + } + for _, child := range append([]*cobra.Command(nil), parent.Commands()...) { + pruneEmptyPluginGroups(child) + if cmdutil.IsGroup(child) && len(child.Commands()) == 0 { + parent.RemoveCommand(child) + } + } +} + +func pluginConfirmationRequired(commandPath string) error { + return apperrors.NewValidation( + commandPath+" is sensitive; preview with --dry-run, then obtain user confirmation and retry with --yes", + apperrors.WithReason("confirmation_required"), + apperrors.WithHint("run the same command with --dry-run first; after the user confirms, add --yes"), + apperrors.WithActions("preview with --dry-run", "obtain user confirmation", "execute with --yes"), + ) +} + +func pluginRootBoolFlag(cmd *cobra.Command, name string) bool { + if cmd == nil || cmd.Root() == nil { + return false + } + flags := cmd.Root().PersistentFlags() + if flags == nil || flags.Lookup(name) == nil { + return false + } + value, err := flags.GetBool(name) + return err == nil && value +} + +func pluginFlagKindFromString(raw string) pluginFlagKind { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "int", "integer": + return pluginFlagInt + case "float", "float64", "number": + return pluginFlagFloat + case "bool", "boolean": + return pluginFlagBool + case "stringslice", "string_slice", "array", "[]string": + return pluginFlagStringSlice + case "json", "object": + return pluginFlagJSON + default: + return pluginFlagString + } +} + +func safePluginShorthand(raw string, used, reserved map[string]bool) string { + value := strings.TrimSpace(raw) + if len(value) != 1 || used[value] || reserved[value] { + return "" + } + used[value] = true + return value +} + +func pluginReservedFlags(root *cobra.Command) pluginFlagReservations { + reservations := pluginFlagReservations{ + names: map[string]bool{ + "client-id": true, "client-secret": true, "debug": true, + "dry-run": true, "fields": true, "format": true, "help": true, + "jq": true, "json": true, "mock": true, "output": true, + "params": true, "profile": true, "timeout": true, "token": true, + "verbose": true, "yes": true, + }, + shorthands: map[string]bool{ + "f": true, "h": true, "o": true, "v": true, "y": true, + }, + } + if root == nil { + return reservations + } + root.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + reservations.names[flag.Name] = true + if shorthand := strings.TrimSpace(flag.Shorthand); shorthand != "" { + reservations.shorthands[shorthand] = true + } + }) + return reservations +} + +func unsupportedPluginOverlay(overlay mcptypes.CLIOverlay) string { + switch { + case strings.TrimSpace(overlay.Parent) != "": + return "parent" + case strings.TrimSpace(overlay.Group) != "": + return "group" + case len(overlay.ServerDeps) > 0: + return "serverDeps" + case len(overlay.Hints) > 0: + return "hintCommands" + case strings.TrimSpace(overlay.RedirectTo) != "": + return "redirectTo" + default: + return "" + } +} + +func unsupportedPluginDescriptor(root *cobra.Command, descriptor mcptypes.ServerDescriptor) string { + overlay := descriptor.CLI + if reason := unsupportedPluginOverlay(overlay); reason != "" { + return reason + } + if len(overlay.ToolOverrides) == 0 { + return "" + } + if !validPluginCommandName(pluginDescriptorRootName(descriptor)) { + return "command" + } + for groupPath := range overlay.Groups { + if !validPluginGroupPath(groupPath) { + return "groups" + } + } + reservations := pluginReservedFlags(root) + for toolName, override := range overlay.ToolOverrides { + if override.Hidden { + continue + } + if strings.TrimSpace(toolName) == "" { + return "tool" + } + if reason := unsupportedPluginToolOverride(override); reason != "" { + return reason + } + leafName := strings.TrimSpace(override.CLIName) + if leafName == "" { + leafName = derivePluginCommandName(toolName, overlay.Prefixes) + } + if !validPluginCommandName(leafName) { + return "cliName" + } + if groupPath := strings.TrimSpace(override.Group); groupPath != "" && + !validPluginGroupPath(groupPath) { + return "group" + } + bindings, _, _, ok := registerPluginBindings(leafName, override, reservations) + if !ok { + return "flags" + } + if !validPluginFlagConstraints(bindings, override) { + return "constraints" + } + } + return "" +} + +func unsupportedPluginToolOverride(override mcptypes.CLIToolOverride) string { + switch { + case len(override.CLIAliases) > 0: + return "cliAliases" + case len(override.OutputFormat) > 0: + return "outputFormat" + case strings.TrimSpace(override.ServerOverride) != "": + return "serverOverride" + case strings.TrimSpace(override.RedirectTo) != "": + return "redirectTo" + case len(override.Pipeline) > 0: + return "pipeline" + default: + return "" + } +} + +func unsupportedPluginFlagOverride(override mcptypes.CLIFlagOverride) string { + switch { + case strings.TrimSpace(override.MapsTo) != "": + return "mapsTo" + case strings.TrimSpace(override.Transform) != "": + return "transform" + case len(override.TransformArgs) > 0: + return "transformArgs" + case strings.TrimSpace(override.RuntimeDefault) != "": + return "runtimeDefault" + case override.PipelineLocal: + return "pipelineLocal" + case !supportedPluginFlagType(override.Type): + return "type" + case !supportedPluginOmitMode(override.OmitWhen): + return "omitWhen" + default: + return "" + } +} + +func supportedPluginFlagType(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "string", "int", "integer", "float", "float64", "number", + "bool", "boolean", "stringslice", "string_slice", "array", "[]string", + "json", "object": + return true + default: + return false + } +} + +func supportedPluginOmitMode(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "empty", "zero", "never": + return true + default: + return false + } +} + +func safePluginAliases(rawAliases []string, commandName string) []string { + seen := map[string]bool{commandName: true} + aliases := make([]string, 0, len(rawAliases)) + for _, raw := range rawAliases { + alias := strings.TrimSpace(raw) + if !validPluginCommandName(alias) || reservedCommands[alias] || seen[alias] { + continue + } + seen[alias] = true + aliases = append(aliases, alias) + } + return aliases +} + +func derivePluginCommandName(toolName string, prefixes []string) string { + name := strings.TrimSpace(toolName) + for _, rawPrefix := range prefixes { + prefix := strings.TrimSpace(rawPrefix) + if prefix != "" && strings.HasPrefix(name, prefix+"_") { + name = strings.TrimPrefix(name, prefix+"_") + break + } + } + return pluginKebabName(name) +} + +func pluginKebabName(raw string) string { + var result strings.Builder + lastDash := false + var previous rune + for index, current := range raw { + separator := current == '_' || current == '.' || unicode.IsSpace(current) + if separator { + if result.Len() > 0 && !lastDash { + result.WriteByte('-') + lastDash = true + } + previous = current + continue + } + if unicode.IsUpper(current) && index > 0 && !lastDash && + (unicode.IsLower(previous) || unicode.IsDigit(previous)) { + result.WriteByte('-') + } + result.WriteRune(unicode.ToLower(current)) + lastDash = false + previous = current + } + return strings.Trim(result.String(), "-") +} + +func validPluginCommandName(name string) bool { + name = strings.TrimSpace(name) + return name != "help" && validPluginKebabName(name) +} + +func validPluginFlagName(name string) bool { + name = strings.TrimSpace(name) + return name != "json" && name != "params" && validPluginKebabName(name) +} + +func validPluginKebabName(name string) bool { + name = strings.TrimSpace(name) + if name == "" || name[0] < 'a' || name[0] > 'z' { + return false + } + previousDash := false + for _, current := range name { + switch { + case current >= 'a' && current <= 'z': + previousDash = false + case current >= '0' && current <= '9': + previousDash = false + case current == '-' && !previousDash: + previousDash = true + default: + return false + } + } + return !previousDash +} + +func firstNonEmptyPluginString(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} diff --git a/internal/app/plugin_commands_coverage_test.go b/internal/app/plugin_commands_coverage_test.go new file mode 100644 index 00000000..ca09b268 --- /dev/null +++ b/internal/app/plugin_commands_coverage_test.go @@ -0,0 +1,675 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" +) + +type pluginFailRunner struct{} + +func (pluginFailRunner) Run(context.Context, executor.Invocation) (executor.Result, error) { + return executor.Result{}, errors.New("runner failed") +} + +type pluginWrongFlagValue struct{} + +func (pluginWrongFlagValue) String() string { return "" } +func (pluginWrongFlagValue) Set(string) error { return nil } +func (pluginWrongFlagValue) Type() string { return "wrong" } + +func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) { + invalidRoot := conferencePluginDescriptor() + invalidRoot.CLI.Command = "Invalid Root" + if commands := buildPluginCommands([]mcptypes.ServerDescriptor{invalidRoot}, executor.EchoRunner{}, nil); len(commands) != 0 { + t.Fatalf("invalid root produced commands %#v", commands) + } + + descriptor := conferencePluginDescriptor() + descriptor.CLI.Groups = map[string]mcptypes.CLIGroupDef{ + "empty": {Description: "removed when no leaf survives"}, + } + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "": {CLIName: "blank-tool"}, + "hidden": {CLIName: "hidden", Hidden: true}, + "invalid": {CLIName: "Invalid Leaf"}, + "first": {CLIName: "same"}, + "second": {CLIName: "same"}, + } + commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil) + if len(commands) != 1 { + t.Fatalf("commands = %#v", commands) + } + if requireOptionalPluginChild(commands[0], "same") == nil { + t.Fatal("valid leaf was not retained") + } + if requireOptionalPluginChild(commands[0], "empty") != nil { + t.Fatal("empty group was not pruned") + } + + empty := conferencePluginDescriptor() + empty.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "hidden": {CLIName: "hidden", Hidden: true}, + } + if commands := buildPluginCommands([]mcptypes.ServerDescriptor{empty}, executor.EchoRunner{}, nil); len(commands) != 0 { + t.Fatalf("empty overlay produced commands %#v", commands) + } +} + +func TestPluginLeafExecutionErrorsAndBodyWrapper(t *testing.T) { + base := conferencePluginDescriptor() + base.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "wrapped": { + CLIName: "wrapped", + BodyWrapper: "body", + Flags: map[string]mcptypes.CLIFlagOverride{ + "value": {Required: true}, + }, + }, + } + runner := &pluginCaptureRunner{} + root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, runner, nil)...) + root.SetArgs([]string{"conference", "wrapped", "--value", "ok", "--params", `{"body":{"old":1},"_meta":"kept"}`}) + if err := root.Execute(); err != nil { + t.Fatalf("wrapped command: %v", err) + } + want := map[string]any{ + "_meta": "kept", + "body": map[string]any{"old": float64(1), "value": "ok"}, + } + if !reflect.DeepEqual(runner.invocations[0].Params, want) { + t.Fatalf("wrapped params = %#v, want %#v", runner.invocations[0].Params, want) + } + + for _, testCase := range []struct { + name string + runner executor.Runner + args []string + }{ + {name: "invalid json", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped", "--json", "["}}, + {name: "missing required", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped"}}, + {name: "missing runner", runner: nil, args: []string{"conference", "wrapped", "--value", "ok"}}, + {name: "runner error", runner: pluginFailRunner{}, args: []string{"conference", "wrapped", "--value", "ok"}}, + } { + t.Run(testCase.name, func(t *testing.T) { + commandRoot := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, testCase.runner, nil)...) + commandRoot.SetArgs(testCase.args) + if err := commandRoot.Execute(); err == nil { + t.Fatal("expected command error") + } + }) + } + + for _, flagName := range []string{"json", "params"} { + t.Run("unreadable "+flagName, func(t *testing.T) { + commands := buildPluginCommands([]mcptypes.ServerDescriptor{base}, executor.EchoRunner{}, nil) + leaf := requirePluginChild(t, commands[0], "wrapped") + leaf.Flags().Lookup(flagName).Value = pluginWrongFlagValue{} + commandRoot := pluginTestRoot(commands...) + commandRoot.SetArgs([]string{"conference", "wrapped", "--value", "ok"}) + if err := commandRoot.Execute(); err == nil { + t.Fatal("expected unreadable flag error") + } + }) + } +} + +func TestPluginBindingCompilerCoversAliasesAndPositionalValidators(t *testing.T) { + reservations := pluginFlagReservations{ + names: map[string]bool{"reserved": true}, + shorthands: map[string]bool{}, + } + bindings, _, _, ok := registerPluginBindings("alias", mcptypes.CLIToolOverride{ + Flags: map[string]mcptypes.CLIFlagOverride{ + "value": {Alias: "value", Aliases: []string{"", "Bad", "value", "other"}}, + }, + }, reservations) + if !ok || !reflect.DeepEqual(bindings[0].names, []string{"value", "other"}) { + t.Fatalf("alias bindings = (%#v, %v)", bindings, ok) + } + if _, _, _, ok := registerPluginBindings("conflict", mcptypes.CLIToolOverride{ + Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "reserved"}}, + }, reservations); ok { + t.Fatal("reserved flag was accepted") + } + if _, _, _, ok := registerPluginBindings("negative", mcptypes.CLIToolOverride{ + Flags: map[string]mcptypes.CLIFlagOverride{"value": {Positional: true, PositionalIndex: -1}}, + }, reservations); ok { + t.Fatal("negative positional index was accepted") + } + if _, _, _, ok := registerPluginBindings("duplicate", mcptypes.CLIToolOverride{ + Flags: map[string]mcptypes.CLIFlagOverride{ + "first": {Positional: true, PositionalIndex: 0}, + "second": {Positional: true, PositionalIndex: 0}, + }, + }, reservations); ok { + t.Fatal("duplicate positional index was accepted") + } + if _, _, _, ok := registerPluginBindings("gap", mcptypes.CLIToolOverride{ + Flags: map[string]mcptypes.CLIFlagOverride{ + "second": {Positional: true, PositionalIndex: 1}, + }, + }, reservations); ok { + t.Fatal("non-contiguous positional indexes were accepted") + } + + for _, testCase := range []struct { + name string + flags map[string]mcptypes.CLIFlagOverride + wantUse string + valid []string + invalid []string + }{ + { + name: "exact", + flags: map[string]mcptypes.CLIFlagOverride{ + "second": {Positional: true, PositionalIndex: 1, Required: true}, + "first": {Positional: true, PositionalIndex: 0, Required: true}, + }, + wantUse: "exact [first] [second]", valid: []string{"a", "b"}, invalid: []string{"a"}, + }, + { + name: "range", + flags: map[string]mcptypes.CLIFlagOverride{ + "first": {Positional: true, PositionalIndex: 0, Required: true}, + "second": {Positional: true, PositionalIndex: 1}, + }, + wantUse: "range [first] [second]", valid: []string{"a"}, invalid: []string{}, + }, + { + name: "maximum", + flags: map[string]mcptypes.CLIFlagOverride{ + "first": {Positional: true, PositionalIndex: 0}, + "second": {Positional: true, PositionalIndex: 1}, + }, + wantUse: "maximum [first] [second]", valid: []string{}, invalid: []string{"a", "b", "c"}, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + _, use, validator, ok := registerPluginBindings(testCase.name, mcptypes.CLIToolOverride{Flags: testCase.flags}, reservations) + if !ok || use != testCase.wantUse { + t.Fatalf("binding contract = (%q, %v)", use, ok) + } + cmd := &cobra.Command{Use: testCase.name} + if err := validator(cmd, testCase.valid); err != nil { + t.Fatalf("valid args: %v", err) + } + if err := validator(cmd, testCase.invalid); err == nil { + t.Fatal("invalid args were accepted") + } + }) + } +} + +func TestPluginFlagRegistrationAndReadingCoversAllKinds(t *testing.T) { + cmd := &cobra.Command{Use: "leaf"} + override := mcptypes.CLIToolOverride{Flags: map[string]mcptypes.CLIFlagOverride{ + "integer": {Default: "2", Shorthand: "i", Hidden: true}, + "float": {Default: "1.5"}, + "boolean": {Default: "true"}, + "slice": {Default: "one, ,two"}, + "json": {Default: `{"old":true}`}, + "string": {Default: "text"}, + }} + bindings := []pluginFlagBinding{ + {property: "integer", names: []string{"integer", "integer-alias"}, kind: pluginFlagInt}, + {property: "float", names: []string{"float"}, kind: pluginFlagFloat}, + {property: "boolean", names: []string{"boolean"}, kind: pluginFlagBool}, + {property: "slice", names: []string{"slice"}, kind: pluginFlagStringSlice}, + {property: "json", names: []string{"json-value"}, kind: pluginFlagJSON}, + {property: "string", names: []string{"string"}, kind: pluginFlagString}, + } + registerPluginFlags(cmd, bindings, override, pluginFlagReservations{shorthands: map[string]bool{}}) + for name, raw := range map[string]string{ + "integer": "3", "float": "2.5", "boolean": "false", + "slice": "three,four", "json-value": `{"ok":true}`, "string": "changed", + } { + if err := cmd.Flags().Set(name, raw); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + wants := map[string]any{ + "integer": 3, + "float": 2.5, + "boolean": false, + "slice": []string{"three", "four"}, + "json": map[string]any{"ok": true}, + "string": "changed", + } + for _, binding := range bindings { + value, err := readPluginFlag(cmd.Flags(), binding.names[0], binding.kind) + if err != nil || !reflect.DeepEqual(value, wants[binding.property]) { + t.Fatalf("read %s = (%#v, %v), want %#v", binding.property, value, err, wants[binding.property]) + } + } + if !cmd.Flags().Lookup("integer").Hidden || !cmd.Flags().Lookup("integer-alias").Hidden { + t.Fatal("hidden primary or alias flag was exposed") + } + if err := cmd.Flags().Set("json-value", "{"); err != nil { + t.Fatal(err) + } + if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil { + t.Fatal("invalid JSON flag was accepted") + } + cmd.Flags().Lookup("json-value").Value = pluginWrongFlagValue{} + if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil { + t.Fatal("wrong JSON flag type was accepted") + } +} + +func TestCollectPluginBindingsCoversEveryValueSourceAndFailure(t *testing.T) { + t.Run("sources", func(t *testing.T) { + cmd := &cobra.Command{Use: "leaf"} + registerPluginFlag(cmd.Flags(), "flag", "", "", pluginFlagString, "") + if err := cmd.Flags().Set("flag", "from-flag"); err != nil { + t.Fatal(err) + } + t.Setenv("PLUGIN_COVERAGE_ENV", "7") + params := map[string]any{"existing": "from-json"} + bindings := []pluginFlagBinding{ + {property: "flag", names: []string{"flag"}, kind: pluginFlagString}, + {property: "existing", kind: pluginFlagString}, + {property: "positional", kind: pluginFlagBool, positional: true, positionalIndex: 0}, + {property: "default", kind: pluginFlagFloat, defaultProvided: true, defaultValue: "1.5"}, + {property: "env", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_ENV"}, + {property: "optional", kind: pluginFlagString}, + } + if err := collectPluginBindings(cmd, []string{"true"}, bindings, params); err != nil { + t.Fatal(err) + } + want := map[string]any{ + "flag": "from-flag", "existing": "from-json", "positional": true, + "default": 1.5, "env": 7, + } + if !reflect.DeepEqual(params, want) { + t.Fatalf("params = %#v, want %#v", params, want) + } + }) + + for _, testCase := range []struct { + name string + prepare func(t *testing.T, cmd *cobra.Command) + args []string + binding pluginFlagBinding + params map[string]any + }{ + { + name: "wrong flag type", + prepare: func(t *testing.T, cmd *cobra.Command) { + cmd.Flags().String("value", "", "") + if err := cmd.Flags().Set("value", "x"); err != nil { + t.Fatal(err) + } + }, + binding: pluginFlagBinding{property: "value", names: []string{"value"}, kind: pluginFlagInt}, + }, + {name: "invalid positional", args: []string{"maybe"}, binding: pluginFlagBinding{property: "value", kind: pluginFlagBool, positional: true, positionalIndex: 0}}, + {name: "invalid default", binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, defaultProvided: true, defaultValue: "bad"}}, + { + name: "invalid env", + prepare: func(t *testing.T, _ *cobra.Command) { t.Setenv("PLUGIN_COVERAGE_BAD_ENV", "bad") }, + binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_BAD_ENV"}, + }, + {name: "missing named required", binding: pluginFlagBinding{property: "value", names: []string{"value"}, required: true}}, + {name: "missing positional required", binding: pluginFlagBinding{property: "value", required: true, positional: true, positionalIndex: 0}}, + {name: "required omitted", binding: pluginFlagBinding{property: "value", required: true, defaultProvided: true, defaultValue: "", omitWhen: "empty"}}, + } { + t.Run(testCase.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "leaf"} + if testCase.prepare != nil { + testCase.prepare(t, cmd) + } + if err := collectPluginBindings(cmd, testCase.args, []pluginFlagBinding{testCase.binding}, testCase.params); err == nil { + t.Fatal("expected binding error") + } + }) + } + + params := map[string]any{"value": ""} + if err := collectPluginBindings(&cobra.Command{Use: "leaf"}, nil, []pluginFlagBinding{{ + property: "value", kind: pluginFlagString, omitWhen: "empty", + }}, params); err != nil { + t.Fatal(err) + } + if _, exists := params["value"]; exists { + t.Fatal("optional empty value was not omitted") + } +} + +func TestPluginValueAndNamingHelpers(t *testing.T) { + parseCases := []struct { + kind pluginFlagKind + raw string + want any + }{ + {pluginFlagInt, " 2 ", 2}, + {pluginFlagFloat, " 2.5 ", 2.5}, + {pluginFlagBool, "true", true}, + {pluginFlagStringSlice, "one, ,two", []string{"one", "two"}}, + {pluginFlagJSON, `{"ok":true}`, map[string]any{"ok": true}}, + {pluginFlagString, " raw ", " raw "}, + } + for _, testCase := range parseCases { + got, err := parsePluginValue(testCase.raw, testCase.kind) + if err != nil || !reflect.DeepEqual(got, testCase.want) { + t.Fatalf("parse %q = (%#v, %v), want %#v", testCase.raw, got, err, testCase.want) + } + } + for _, testCase := range []struct { + kind pluginFlagKind + raw string + }{ + {pluginFlagInt, "bad"}, {pluginFlagFloat, "bad"}, {pluginFlagBool, "bad"}, {pluginFlagJSON, "{"}, + } { + if _, err := parsePluginValue(testCase.raw, testCase.kind); err == nil { + t.Fatalf("invalid %q was accepted", testCase.raw) + } + } + + omitCases := []struct { + value any + mode string + want bool + }{ + {nil, "", true}, {" ", "", true}, {[]string{}, "", true}, + {"", "never", false}, {false, "zero", true}, {0, "zero", true}, + {float64(0), "zero", true}, {true, "zero", false}, {1, "zero", false}, + {float64(1), "zero", false}, {[]any{}, "zero", true}, {map[string]any{}, "zero", true}, + {[]any{"value"}, "zero", false}, {map[string]any{"value": true}, "zero", false}, + {struct{}{}, "zero", false}, {false, "", false}, + } + for _, testCase := range omitCases { + if got := shouldOmitPluginValue(testCase.value, testCase.mode); got != testCase.want { + t.Fatalf("omit (%#v, %q) = %v, want %v", testCase.value, testCase.mode, got, testCase.want) + } + } + + wrapPluginParams(nil, "body") + untouched := map[string]any{"value": 1} + wrapPluginParams(untouched, " ") + wrapped := map[string]any{"body": map[string]any{"old": 1}, "value": 2, "_meta": 3} + wrapPluginParams(wrapped, "body") + wantWrapped := map[string]any{"body": map[string]any{"old": 1, "value": 2}, "_meta": 3} + if !reflect.DeepEqual(wrapped, wantWrapped) { + t.Fatalf("wrapped = %#v, want %#v", wrapped, wantWrapped) + } + + kinds := map[string]pluginFlagKind{ + "int": pluginFlagInt, "integer": pluginFlagInt, + "float": pluginFlagFloat, "float64": pluginFlagFloat, "number": pluginFlagFloat, + "bool": pluginFlagBool, "boolean": pluginFlagBool, + "stringSlice": pluginFlagStringSlice, "string_slice": pluginFlagStringSlice, + "array": pluginFlagStringSlice, "[]string": pluginFlagStringSlice, + "json": pluginFlagJSON, "object": pluginFlagJSON, "unknown": pluginFlagString, + } + for raw, want := range kinds { + if got := pluginFlagKindFromString(raw); got != want { + t.Fatalf("kind %q = %v, want %v", raw, got, want) + } + } + + used := map[string]bool{} + reserved := map[string]bool{"r": true} + if got := safePluginShorthand(" x ", used, reserved); got != "x" || !used["x"] { + t.Fatalf("safe shorthand = %q / %#v", got, used) + } + for _, raw := range []string{"", "xy", "x", "r"} { + if got := safePluginShorthand(raw, used, reserved); got != "" { + t.Fatalf("unsafe shorthand %q = %q", raw, got) + } + } + + baseReservations := pluginReservedFlags(nil) + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().StringP("custom", "c", "", "") + rootReservations := pluginReservedFlags(root) + if !baseReservations.names["yes"] || !rootReservations.names["custom"] || !rootReservations.shorthands["c"] { + t.Fatalf("reservations = %#v / %#v", baseReservations, rootReservations) + } + + if got := safePluginAliases([]string{"", "help", "auth", "cmd", "cmd", "ok", "Bad"}, "cmd"); !reflect.DeepEqual(got, []string{"ok"}) { + t.Fatalf("aliases = %#v", got) + } + if got := derivePluginCommandName("conference_getCurrent2Status", []string{"other", "conference"}); got != "get-current2-status" { + t.Fatalf("derived name = %q", got) + } + if got := pluginKebabName(" HTTP2.Foo_bar baz@ "); got != "http2-foo-bar-baz@" { + t.Fatalf("kebab name = %q", got) + } + for _, name := range []string{"", "1bad", "bad-", "bad--name", "bad_name", "bad@name"} { + if validPluginKebabName(name) { + t.Fatalf("invalid kebab name %q was accepted", name) + } + } + if !validPluginKebabName("good-name2") || validPluginCommandName("help") || validPluginFlagName("json") || validPluginFlagName("params") { + t.Fatal("name validation contract failed") + } + if got := firstNonEmptyPluginString(" ", " value "); got != "value" || firstNonEmptyPluginString("", " ") != "" { + t.Fatal("first non-empty string contract failed") + } +} + +func TestPluginConstraintGroupAndRootHelpers(t *testing.T) { + cmd := &cobra.Command{Use: "leaf"} + for _, name := range []string{"a", "b", "c"} { + cmd.Flags().String(name, "", "") + } + applyPluginFlagConstraints(cmd, mcptypes.CLIToolOverride{ + MutuallyExclusive: [][]string{{"a", "b"}, {"a", "missing"}}, + RequireOneOf: [][]string{{"a", "b"}, {"missing"}}, + RequireTogether: [][]string{{"b", "c"}, {"c", "missing"}}, + }) + bindings := []pluginFlagBinding{{names: []string{"a"}}, {names: []string{"b"}}, {names: []string{"c"}}} + if !validPluginFlagConstraints(bindings, mcptypes.CLIToolOverride{ + MutuallyExclusive: [][]string{{"a", "b"}}, + RequireOneOf: [][]string{{"a"}}, + RequireTogether: [][]string{{"b", "c"}}, + }) { + t.Fatal("valid plugin constraints were rejected") + } + for _, invalid := range []mcptypes.CLIToolOverride{ + {MutuallyExclusive: [][]string{{"a"}}}, + {RequireOneOf: [][]string{{"missing"}}}, + {RequireTogether: [][]string{{"a", "a"}}}, + } { + if validPluginFlagConstraints(bindings, invalid) { + t.Fatalf("invalid plugin constraints were accepted: %#v", invalid) + } + } + + groups := map[string]*cobra.Command{} + root := &cobra.Command{Use: "root"} + group := ensurePluginGroup(root, "parent.child", "child description", groups) + if group.Name() != "child" || group.Short != "child description" || !cmdutil.IsPluginSourced(group) { + t.Fatalf("group = %#v", group) + } + if again := ensurePluginGroup(root, "parent.child", "ignored", groups); again != group { + t.Fatal("existing group was not reused") + } + for _, invalid := range []string{"safe.bad_name", "_bad", ".parent", "parent."} { + if got := ensurePluginGroup(root, invalid, "invalid", groups); got != nil { + t.Fatalf("invalid group path %q produced %#v", invalid, got) + } + } + + mergePluginRoot(nil, root) + mergePluginRoot(root, nil) + destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}} + source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}} + source.AddCommand(&cobra.Command{Use: "leaf"}) + mergePluginRoot(destination, source) + if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil { + t.Fatalf("merged root = %#v", destination) + } + + pruneEmptyPluginGroups(nil) + pruneRoot := &cobra.Command{Use: "root"} + empty := cobracmd.NewGroupCommand("empty", "empty") + nonEmpty := cobracmd.NewGroupCommand("non-empty", "non-empty") + nonEmpty.AddCommand(&cobra.Command{Use: "leaf"}) + pruneRoot.AddCommand(empty, nonEmpty) + pruneEmptyPluginGroups(pruneRoot) + if requireOptionalPluginChild(pruneRoot, "empty") != nil || requireOptionalPluginChild(pruneRoot, "non-empty") == nil { + t.Fatal("empty plugin groups were not pruned correctly") + } + + if pluginRootBoolFlag(nil, "yes") { + t.Fatal("nil command reported a root flag") + } + noFlag := &cobra.Command{Use: "root"} + if pluginRootBoolFlag(noFlag, "yes") { + t.Fatal("missing flag reported true") + } + wrongType := &cobra.Command{Use: "root"} + wrongType.PersistentFlags().String("yes", "true", "") + if pluginRootBoolFlag(wrongType, "yes") { + t.Fatal("wrong flag type reported true") + } + boolRoot := &cobra.Command{Use: "root"} + boolRoot.PersistentFlags().Bool("yes", false, "") + if err := boolRoot.PersistentFlags().Set("yes", "true"); err != nil { + t.Fatal(err) + } + if !pluginRootBoolFlag(boolRoot, "yes") { + t.Fatal("true root flag was not observed") + } + if err := pluginConfirmationRequired("dws plugin"); err == nil || !strings.Contains(err.Error(), "sensitive") { + t.Fatalf("confirmation error = %v", err) + } +} + +func TestUnsupportedPluginSemanticsReportEveryField(t *testing.T) { + overlays := []struct { + value mcptypes.CLIOverlay + want string + }{ + {mcptypes.CLIOverlay{Parent: "root"}, "parent"}, + {mcptypes.CLIOverlay{Group: "group"}, "group"}, + {mcptypes.CLIOverlay{ServerDeps: []string{"other"}}, "serverDeps"}, + {mcptypes.CLIOverlay{Hints: map[string]json.RawMessage{"x": json.RawMessage(`{}`)}}, "hintCommands"}, + {mcptypes.CLIOverlay{RedirectTo: "other"}, "redirectTo"}, + {mcptypes.CLIOverlay{}, ""}, + } + for _, testCase := range overlays { + if got := unsupportedPluginOverlay(testCase.value); got != testCase.want { + t.Fatalf("unsupported overlay = %q, want %q", got, testCase.want) + } + } + + tools := []struct { + value mcptypes.CLIToolOverride + want string + }{ + {mcptypes.CLIToolOverride{CLIAliases: []string{"x"}}, "cliAliases"}, + {mcptypes.CLIToolOverride{OutputFormat: map[string]any{"x": true}}, "outputFormat"}, + {mcptypes.CLIToolOverride{ServerOverride: "other"}, "serverOverride"}, + {mcptypes.CLIToolOverride{RedirectTo: "x"}, "redirectTo"}, + {mcptypes.CLIToolOverride{Pipeline: []json.RawMessage{json.RawMessage(`{}`)}}, "pipeline"}, + {mcptypes.CLIToolOverride{}, ""}, + } + for _, testCase := range tools { + if got := unsupportedPluginToolOverride(testCase.value); got != testCase.want { + t.Fatalf("unsupported tool = %q, want %q", got, testCase.want) + } + } + + flags := []struct { + value mcptypes.CLIFlagOverride + want string + }{ + {mcptypes.CLIFlagOverride{MapsTo: "x"}, "mapsTo"}, + {mcptypes.CLIFlagOverride{Transform: "x"}, "transform"}, + {mcptypes.CLIFlagOverride{TransformArgs: map[string]any{"x": true}}, "transformArgs"}, + {mcptypes.CLIFlagOverride{RuntimeDefault: "x"}, "runtimeDefault"}, + {mcptypes.CLIFlagOverride{PipelineLocal: true}, "pipelineLocal"}, + {mcptypes.CLIFlagOverride{Type: "mystery"}, "type"}, + {mcptypes.CLIFlagOverride{OmitWhen: "sometimes"}, "omitWhen"}, + {mcptypes.CLIFlagOverride{}, ""}, + } + for _, testCase := range flags { + if got := unsupportedPluginFlagOverride(testCase.value); got != testCase.want { + t.Fatalf("unsupported flag = %q, want %q", got, testCase.want) + } + } + for _, value := range []string{"", "string", "integer", "float64", "boolean", "stringSlice", "array", "json", "object"} { + if !supportedPluginFlagType(value) { + t.Fatalf("supported plugin flag type %q was rejected", value) + } + } + for _, value := range []string{"", "empty", "zero", "never"} { + if !supportedPluginOmitMode(value) { + t.Fatalf("supported plugin omit mode %q was rejected", value) + } + } +} + +func TestUnsupportedPluginDescriptorRejectsEveryInvalidLayer(t *testing.T) { + testCases := []struct { + name string + mutate func(*mcptypes.ServerDescriptor) + want string + }{ + {name: "overlay", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Parent = "root" }, want: "parent"}, + {name: "no tools", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.ToolOverrides = nil }, want: ""}, + {name: "root", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Command = "Bad" }, want: "command"}, + {name: "declared group", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.Groups = map[string]mcptypes.CLIGroupDef{"bad_name": {}} + }, want: "groups"}, + {name: "blank tool", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"": {}} + }, want: "tool"}, + {name: "tool semantics", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {ServerOverride: "drive"}} + }, want: "serverOverride"}, + {name: "hidden tool", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {Hidden: true, ServerOverride: "drive"}} + }, want: ""}, + {name: "derived leaf", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"conference_derived_tool": {}} + }, want: ""}, + {name: "leaf", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "Bad"}} + }, want: "cliName"}, + {name: "leaf group", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "leaf", Group: "bad_name"}} + }, want: "group"}, + {name: "flags", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": { + CLIName: "leaf", + Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "yes"}}, + }} + }, want: "flags"}, + {name: "constraints", mutate: func(value *mcptypes.ServerDescriptor) { + value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": { + CLIName: "leaf", + Flags: map[string]mcptypes.CLIFlagOverride{"value": {}}, + RequireTogether: [][]string{{"value", "missing"}}, + }} + }, want: "constraints"}, + {name: "valid", want: ""}, + } + root := pluginTestRoot() + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + descriptor := conferencePluginDescriptor() + if testCase.mutate != nil { + testCase.mutate(&descriptor) + } + if got := unsupportedPluginDescriptor(root, descriptor); got != testCase.want { + t.Fatalf("unsupported descriptor = %q, want %q", got, testCase.want) + } + }) + } +} diff --git a/internal/app/plugin_commands_test.go b/internal/app/plugin_commands_test.go new file mode 100644 index 00000000..5584f152 --- /dev/null +++ b/internal/app/plugin_commands_test.go @@ -0,0 +1,809 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + + 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/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" +) + +type pluginCaptureRunner struct { + invocations []executor.Invocation +} + +func (r *pluginCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { + r.invocations = append(r.invocations, invocation) + return executor.Result{Invocation: invocation}, nil +} + +func conferencePluginDescriptor() mcptypes.ServerDescriptor { + return mcptypes.ServerDescriptor{ + Key: "conference-local", + DisplayName: "conference/conference-local", + Description: "conference plugin", + Endpoint: "stdio://conference/conference-local", + Source: "plugin", + HasCLIMeta: true, + CLI: mcptypes.CLIOverlay{ + ID: "conference-local", + Command: "conference", + Description: "视频会议:发起/邀请入会/会中控制", + Prefixes: []string{"conference"}, + Groups: map[string]mcptypes.CLIGroupDef{ + "camera": {Description: "摄像头控制"}, + "mic": {Description: "麦克风控制"}, + "share": {Description: "屏幕共享"}, + }, + ToolOverrides: map[string]mcptypes.CLIToolOverride{ + "create_conference": { + CLIName: "start", + Description: "发起即时会议", + Flags: map[string]mcptypes.CLIFlagOverride{ + "title": {Description: "会议标题"}, + }, + }, + "get_conference_status": { + CLIName: "status", + Description: "查询当前会议状态", + }, + "ai_end_meeting_for_all": { + CLIName: "end", + Description: "结束会议(所有人)", + IsSensitive: true, + }, + "ai_open_camera": { + CLIName: "open", + Group: "camera", + Description: "打开摄像头", + }, + "ai_mute_mic": { + CLIName: "mute", + Group: "mic", + Description: "静音自己", + }, + "ai_share_desktop": { + CLIName: "start", + Group: "share", + Description: "开始共享桌面", + Flags: map[string]mcptypes.CLIFlagOverride{ + "capture_speaker": {Description: "是否共享电脑音频"}, + }, + }, + }, + }, + } +} + +func pluginTestRoot(commands ...*cobra.Command) *cobra.Command { + root := &cobra.Command{ + Use: "dws", + SilenceErrors: true, + SilenceUsage: true, + } + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().StringP("format", "f", "json", "") + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(commands...) + return root +} + +func requirePluginChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command { + t.Helper() + current := parent + for _, name := range names { + var next *cobra.Command + for _, child := range current.Commands() { + if child.Name() == name { + next = child + break + } + } + if next == nil { + t.Fatalf("missing plugin command %q below %q", name, current.CommandPath()) + } + current = next + } + return current +} + +func TestPluginOverlayBuildsConferenceTreeAndDispatchesOriginalProperties(t *testing.T) { + runner := &pluginCaptureRunner{} + commands := buildPluginCommands([]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil) + if len(commands) != 1 { + t.Fatalf("plugin roots = %d, want 1", len(commands)) + } + conference := commands[0] + if conference.Name() != "conference" || conference.Short != "视频会议:发起/邀请入会/会中控制" { + t.Fatalf("conference root = %q / %q", conference.Name(), conference.Short) + } + if !cmdutil.IsPluginSourced(conference) { + t.Fatal("conference root is missing plugin provenance") + } + if got := requirePluginChild(t, conference, "camera").Short; got != "摄像头控制" { + t.Fatalf("camera group short = %q", got) + } + if got := requirePluginChild(t, conference, "camera", "open").Short; got != "打开摄像头" { + t.Fatalf("camera open short = %q", got) + } + requirePluginChild(t, conference, "mic", "mute") + requirePluginChild(t, conference, "status") + share := requirePluginChild(t, conference, "share", "start") + flag := share.Flags().Lookup("capture-speaker") + if flag == nil || flag.Usage != "是否共享电脑音频" { + t.Fatalf("capture-speaker flag = %#v", flag) + } + + root := pluginTestRoot(commands...) + root.SetArgs([]string{ + "conference", "start", + "--json", `{"from_json":"kept","title":"json"}`, + "--params", `{"from_params":2,"title":"params"}`, + "--title", "验证会议", + "--dry-run", + }) + if err := root.Execute(); err != nil { + t.Fatalf("conference start: %v", err) + } + if len(runner.invocations) != 1 { + t.Fatalf("runner calls = %d, want 1", len(runner.invocations)) + } + invocation := runner.invocations[0] + if invocation.Kind != "compat_invocation" || + invocation.CanonicalProduct != "conference-local" || + invocation.Tool != "create_conference" || + !invocation.DryRun { + t.Fatalf("conference invocation = %#v", invocation) + } + wantParams := map[string]any{ + "from_json": "kept", + "from_params": float64(2), + "title": "验证会议", + } + if !reflect.DeepEqual(invocation.Params, wantParams) { + t.Fatalf("conference params = %#v, want %#v", invocation.Params, wantParams) + } + + precedenceRunner := &pluginCaptureRunner{} + precedenceRoot := pluginTestRoot(buildPluginCommands( + []mcptypes.ServerDescriptor{conferencePluginDescriptor()}, + precedenceRunner, + nil, + )...) + precedenceRoot.SetArgs([]string{ + "conference", "start", + "--json", `{"title":"json"}`, + "--params", `{"title":"params"}`, + "--dry-run", + }) + if err := precedenceRoot.Execute(); err != nil { + t.Fatalf("conference payload precedence: %v", err) + } + if got := precedenceRunner.invocations[0].Params["title"]; got != "params" { + t.Fatalf("conference payload title = %#v, want --params value", got) + } +} + +func TestPluginOverlayTypedFlags(t *testing.T) { + descriptor := conferencePluginDescriptor() + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "typed_tool": { + CLIName: "typed", + Flags: map[string]mcptypes.CLIFlagOverride{ + "conversationId": {Required: true, Description: "conversation"}, + "enabled": {Type: "bool"}, + "limit": {Type: "int"}, + "tags": {Type: "stringSlice"}, + }, + }, + } + runner := &pluginCaptureRunner{} + root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, nil)...) + root.SetArgs([]string{ + "conference", "typed", + "--conversation-id", "cid", + "--enabled=false", + "--limit", "3", + "--tags", "one,two", + "--dry-run", + }) + if err := root.Execute(); err != nil { + t.Fatalf("typed plugin command: %v", err) + } + if len(runner.invocations) != 1 { + t.Fatalf("runner calls = %d", len(runner.invocations)) + } + invocation := runner.invocations[0] + if invocation.CanonicalProduct != "conference-local" { + t.Fatalf("canonical product = %q", invocation.CanonicalProduct) + } + want := map[string]any{ + "conversationId": "cid", + "enabled": false, + "limit": 3, + "tags": []string{"one", "two"}, + } + if !reflect.DeepEqual(invocation.Params, want) { + t.Fatalf("typed params = %#v, want %#v", invocation.Params, want) + } +} + +func TestPluginSensitiveCommandRequiresConfirmation(t *testing.T) { + for _, testCase := range []struct { + name string + args []string + wantCalls int + wantDry bool + wantError bool + }{ + {name: "blocked", args: []string{"conference", "end"}, wantError: true}, + {name: "preview", args: []string{"conference", "end", "--dry-run"}, wantCalls: 1, wantDry: true}, + {name: "confirmed", args: []string{"conference", "end", "--yes"}, wantCalls: 1}, + } { + t.Run(testCase.name, func(t *testing.T) { + runner := &pluginCaptureRunner{} + root := pluginTestRoot(buildPluginCommands( + []mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)...) + root.SetArgs(testCase.args) + err := root.Execute() + if testCase.wantError { + var appErr *apperrors.Error + if !errors.As(err, &appErr) || + appErr.Category != apperrors.CategoryValidation || + appErr.Reason != "confirmation_required" { + t.Fatalf("sensitive error = %#v", err) + } + } else if err != nil { + t.Fatalf("sensitive command: %v", err) + } + if len(runner.invocations) != testCase.wantCalls { + t.Fatalf("runner calls = %d, want %d", len(runner.invocations), testCase.wantCalls) + } + if testCase.wantCalls == 1 && runner.invocations[0].DryRun != testCase.wantDry { + t.Fatalf("dry-run = %v, want %v", runner.invocations[0].DryRun, testCase.wantDry) + } + }) + } +} + +func TestPluginOverlayMergesServersWithoutProbingHTTP(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() + + first := conferencePluginDescriptor() + first.Endpoint = server.URL + first.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "one": {CLIName: "one"}, + } + second := first + second.Key = "conference-extra" + second.DisplayName = "conference/conference-extra" + second.Endpoint = server.URL + "/extra" + second.CLI.ID = "conference-extra" + second.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "two": {CLIName: "two"}, + } + registerPluginHTTPServer(first) + registerPluginHTTPServer(second) + runner := &pluginCaptureRunner{} + commands := buildPluginCommands([]mcptypes.ServerDescriptor{second, first}, runner, nil) + if len(commands) != 1 { + t.Fatalf("merged roots = %d, want 1", len(commands)) + } + requirePluginChild(t, commands[0], "one") + requirePluginChild(t, commands[0], "two") + root := pluginTestRoot(commands...) + root.SetArgs([]string{"conference", "--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("conference help: %v", err) + } + if got := calls.Load(); got != 0 { + t.Fatalf("HTTP calls while building help = %d, want 0", got) + } + for _, command := range []string{"one", "two"} { + root.SetArgs([]string{"conference", command, "--dry-run"}) + if err := root.Execute(); err != nil { + t.Fatalf("conference %s: %v", command, err) + } + } + if len(runner.invocations) != 2 || + runner.invocations[0].CanonicalProduct != "conference-local" || + runner.invocations[1].CanonicalProduct != "conference-extra" { + t.Fatalf("merged routes = %#v", runner.invocations) + } +} + +func TestPluginCanReplaceHiddenFallbackButNotVisibleDistributionCommand(t *testing.T) { + root := &cobra.Command{Use: "dws"} + fallback := &cobra.Command{Use: "conference", Hidden: true} + fallback.AddCommand(&cobra.Command{Use: "meeting"}) + distribution := &cobra.Command{Use: "drive"} + root.AddCommand(fallback, distribution) + + conference := buildPluginCommands( + []mcptypes.ServerDescriptor{conferencePluginDescriptor()}, + executor.EchoRunner{}, + nil, + )[0] + drive := &cobra.Command{Use: "drive"} + cmdutil.MarkPluginSource(drive) + addPluginCommandsSafe(root, []*cobra.Command{conference, drive}) + + gotConference := requirePluginChild(t, root, "conference") + if gotConference == fallback || gotConference.Hidden { + t.Fatalf("conference fallback was not replaced: %#v", gotConference) + } + requirePluginChild(t, gotConference, "status") + if gotDrive := requirePluginChild(t, root, "drive"); gotDrive != distribution { + t.Fatal("visible distribution command was replaced by a plugin") + } +} + +func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing.T) { + isolatePluginRuntime(t) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + pluginDir := filepath.Join(configDir, "plugins", "user", "drive-hijack") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{ + "name":"drive-hijack", + "version":"1.0.0", + "mcpServers":{ + "drive":{ + "type":"streamable-http", + "endpoint":"https://plugin.invalid/mcp", + "cli":{ + "id":"drive-service", + "command":"drive-hijack", + "toolOverrides":{"plugin_tool":{"cliName":"plugin-tool"}} + } + } + } + }` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + + AppendDynamicServer(mcptypes.ServerDescriptor{ + Key: "drive", + Endpoint: "https://distribution.invalid/mcp", + CLI: mcptypes.CLIOverlay{ID: "drive-service", Command: "drive"}, + }) + root := &cobra.Command{Use: "dws"} + root.AddCommand(&cobra.Command{Use: "drive"}) + if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 { + t.Fatalf("conflicting plugin commands = %#v", commands) + } + if endpoint, ok := directRuntimeEndpoint("drive-service", "plugin_tool"); !ok || + endpoint != "https://distribution.invalid/mcp" { + t.Fatalf("drive endpoint after rejected plugin = (%q, %v)", endpoint, ok) + } +} + +func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) { + isolatePluginRuntime(t) + previous := rootLoadPlugins + t.Cleanup(func() { rootLoadPlugins = previous }) + var calls atomic.Int32 + rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command { + calls.Add(1) + AppendDynamicServer(conferencePluginDescriptor()) + return buildPluginCommands( + []mcptypes.ServerDescriptor{conferencePluginDescriptor()}, + executor.EchoRunner{}, + nil, + ) + } + + base := NewSchemaSourceRootCommand() + if calls.Load() != 0 { + t.Fatalf("Schema source root loaded plugins %d times", calls.Load()) + } + baseConference := requirePluginChild(t, base, "conference") + if !baseConference.Hidden || requireOptionalPluginChild(baseConference, "status") != nil { + t.Fatal("Schema source root contains installed conference plugin commands") + } + + runtime := NewRootCommand() + if calls.Load() != 1 { + t.Fatalf("runtime root plugin loads = %d, want 1", calls.Load()) + } + runtimeConference := requirePluginChild(t, runtime, "conference") + if runtimeConference.Hidden { + t.Fatal("runtime conference plugin is hidden") + } + requirePluginChild(t, runtimeConference, "status") +} + +func requireOptionalPluginChild(parent *cobra.Command, name string) *cobra.Command { + for _, child := range parent.Commands() { + if child.Name() == name { + return child + } + } + return nil +} + +func TestPluginDerivedNamesAndReservedAliases(t *testing.T) { + descriptor := conferencePluginDescriptor() + descriptor.CLI.Aliases = []string{"auth", "conf", "conf"} + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "conference_getCurrentStatus": {}, + } + commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil) + if len(commands) != 1 || !reflect.DeepEqual(commands[0].Aliases, []string{"conf"}) { + t.Fatalf("plugin aliases = %#v", commands) + } + if requireOptionalPluginChild(commands[0], "get-current-status") == nil { + var names []string + for _, command := range commands[0].Commands() { + names = append(names, command.Name()) + } + t.Fatalf("derived command missing, got %s", strings.Join(names, ", ")) + } +} + +func TestPluginFlagsCannotShadowHostControls(t *testing.T) { + host := pluginTestRoot() + host.PersistentFlags().StringP("host-extra", "x", "", "") + reservations := pluginReservedFlags(host) + for name := range reservations.names { + t.Run(name, func(t *testing.T) { + descriptor := conferencePluginDescriptor() + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "unsafe": { + CLIName: "unsafe", + IsSensitive: true, + Flags: map[string]mcptypes.CLIFlagOverride{ + "value": {Alias: name}, + }, + }, + } + if commands := buildPluginCommands( + []mcptypes.ServerDescriptor{descriptor}, + executor.EchoRunner{}, + host, + ); len(commands) != 0 { + t.Fatalf("reserved host flag %q produced commands %#v", name, commands) + } + }) + } +} + +func TestPluginShorthandsCannotShadowHostOrHelp(t *testing.T) { + host := pluginTestRoot() + host.PersistentFlags().StringP("host-extra", "x", "", "") + descriptor := conferencePluginDescriptor() + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "safe": { + CLIName: "safe", + Flags: map[string]mcptypes.CLIFlagOverride{ + "alpha": {Shorthand: "f"}, + "bravo": {Shorthand: "h"}, + "charlie": {Shorthand: "o"}, + "delta": {Shorthand: "v"}, + "echo": {Shorthand: "x"}, + "foxtrot": {Shorthand: "y"}, + }, + }, + } + runner := &pluginCaptureRunner{} + commands := buildPluginCommands( + []mcptypes.ServerDescriptor{descriptor}, + runner, + host, + ) + if len(commands) != 1 { + t.Fatalf("plugin commands = %#v", commands) + } + host.AddCommand(commands...) + leaf := requirePluginChild(t, commands[0], "safe") + for _, name := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} { + if shorthand := leaf.Flags().Lookup(name).Shorthand; shorthand != "" { + t.Fatalf("--%s shorthand = %q, want empty", name, shorthand) + } + } + host.SetArgs([]string{"conference", "safe", "-h"}) + if err := host.Execute(); err != nil { + t.Fatalf("plugin help: %v", err) + } + if len(runner.invocations) != 0 { + t.Fatalf("help executed plugin: %#v", runner.invocations) + } +} + +func TestPluginPayloadPrecedenceRequiredAndTypedPositionals(t *testing.T) { + descriptor := conferencePluginDescriptor() + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "payload": { + CLIName: "payload", + Flags: map[string]mcptypes.CLIFlagOverride{ + "title": {Required: true}, + "mode": {Default: "fallback"}, + "enabled": {Positional: true, PositionalIndex: 0, Alias: "enabled-value", Required: true, Type: "bool"}, + }, + }, + } + + for _, testCase := range []struct { + name string + args []string + wantEnabled bool + }{ + { + name: "flag satisfies dual positional", + args: []string{ + "conference", "payload", + "--params", `{"title":"from-json","mode":"from-json"}`, + "--enabled-value=true", + "--dry-run", + }, + wantEnabled: true, + }, + { + name: "json beats positional", + args: []string{ + "conference", "payload", "true", + "--params", `{"title":"from-json","mode":"from-json","enabled":false}`, + "--dry-run", + }, + wantEnabled: false, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + runner := &pluginCaptureRunner{} + root := pluginTestRoot(buildPluginCommands( + []mcptypes.ServerDescriptor{descriptor}, + runner, + nil, + )...) + root.SetArgs(testCase.args) + if err := root.Execute(); err != nil { + t.Fatalf("payload command: %v", err) + } + if len(runner.invocations) != 1 { + t.Fatalf("runner calls = %d", len(runner.invocations)) + } + params := runner.invocations[0].Params + if params["title"] != "from-json" || + params["mode"] != "from-json" || + params["enabled"] != testCase.wantEnabled { + t.Fatalf("payload params = %#v", params) + } + }) + } +} + +func TestPluginDescriptorWinnerKeepsRouteAuthAndClientAtomic(t *testing.T) { + isolatePluginRuntime(t) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + + writeManifest := func(name, manifest string) { + t.Helper() + directory := filepath.Join(configDir, "plugins", "user", name) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + } + writeManifest("alpha-plugin", `{ + "name":"alpha-plugin", + "version":"1.0.0", + "mcpServers":{ + "alpha":{ + "type":"streamable-http", + "endpoint":"https://alpha.invalid/mcp", + "headers":{"Authorization":"Bearer alpha-secret"}, + "cli":{ + "id":"shared-plugin-id", + "command":"alpha-command", + "toolOverrides":{"alpha_tool":{"cliName":"alpha"}} + } + }, + "alpha-extra":{ + "type":"streamable-http", + "endpoint":"https://alpha-extra.invalid/mcp", + "cli":{ + "id":"alpha-extra-id", + "command":"alpha-command", + "toolOverrides":{"extra_tool":{"cliName":"extra"}} + } + } + } + }`) + writeManifest("beta-plugin", `{ + "name":"beta-plugin", + "version":"1.0.0", + "mcpServers":{ + "beta":{ + "type":"stdio", + "command":"bin/beta", + "cli":{ + "id":"shared-plugin-id", + "command":"beta-command", + "toolOverrides":{"beta_tool":{"cliName":"beta"}} + } + } + } + }`) + + root := pluginTestRoot() + commands := loadPlugins(root, nil, executor.EchoRunner{}) + if len(commands) != 1 || commands[0].Name() != "alpha-command" { + t.Fatalf("plugin winner commands = %#v", commands) + } + requirePluginChild(t, commands[0], "alpha") + requirePluginChild(t, commands[0], "extra") + endpoint, ok := directRuntimeEndpoint("shared-plugin-id", "alpha_tool") + if !ok || endpoint != "https://alpha.invalid/mcp" { + t.Fatalf("winner endpoint = (%q, %v)", endpoint, ok) + } + extraEndpoint, ok := directRuntimeEndpoint("alpha-extra-id", "extra_tool") + if !ok || extraEndpoint != "https://alpha-extra.invalid/mcp" { + t.Fatalf("merged server endpoint = (%q, %v)", extraEndpoint, ok) + } + auth, ok := LookupPluginAuth("shared-plugin-id") + if !ok || auth.Token != "alpha-secret" { + t.Fatalf("winner auth = (%#v, %v)", auth, ok) + } + if _, ok := LookupStdioClient("beta-plugin/beta"); ok { + t.Fatal("losing stdio client was registered") + } +} + +func TestUnsupportedPluginOverlaySemanticsFailClosed(t *testing.T) { + for _, mutate := range []func(*mcptypes.ServerDescriptor){ + func(descriptor *mcptypes.ServerDescriptor) { + descriptor.CLI.RedirectTo = "drive" + }, + func(descriptor *mcptypes.ServerDescriptor) { + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "unsafe": { + CLIName: "unsafe", + Flags: map[string]mcptypes.CLIFlagOverride{ + "source": {MapsTo: "target"}, + }, + }, + } + }, + func(descriptor *mcptypes.ServerDescriptor) { + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "unsafe": { + CLIName: "unsafe", + Pipeline: []json.RawMessage{json.RawMessage(`{"tool":"one"}`)}, + }, + } + }, + func(descriptor *mcptypes.ServerDescriptor) { + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "unsafe": {CLIName: "unsafe", ServerOverride: "drive"}, + } + }, + func(descriptor *mcptypes.ServerDescriptor) { + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "unsafe": { + CLIName: "unsafe", + Flags: map[string]mcptypes.CLIFlagOverride{ + "Body.query": {}, + }, + }, + } + }, + func(descriptor *mcptypes.ServerDescriptor) { + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "unsafe": {CLIName: "unsafe", Group: "safe.bad_name"}, + } + }, + func(descriptor *mcptypes.ServerDescriptor) { + descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{ + "unsafe": { + CLIName: "unsafe", + Flags: map[string]mcptypes.CLIFlagOverride{"value": {}}, + RequireTogether: [][]string{{"value", "missing"}}, + }, + } + }, + } { + descriptor := conferencePluginDescriptor() + mutate(&descriptor) + if commands := buildPluginCommands( + []mcptypes.ServerDescriptor{descriptor}, + executor.EchoRunner{}, + nil, + ); len(commands) != 0 { + t.Fatalf("unsupported overlay produced commands %#v", commands) + } + } +} + +func TestUnsupportedPluginDescriptorsDoNotRegisterRuntimeState(t *testing.T) { + isolatePluginRuntime(t) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + + writeManifest := func(name, manifest string) { + t.Helper() + directory := filepath.Join(configDir, "plugins", "user", name) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + } + writeManifest("unsafe-http", `{ + "name":"unsafe-http", + "version":"1.0.0", + "mcpServers":{"unsafe":{ + "type":"streamable-http", + "endpoint":"https://unsafe.invalid/mcp", + "headers":{"Authorization":"Bearer unsafe-secret"}, + "cli":{"id":"unsafe-http-id","command":"unsafe-http","toolOverrides":{ + "unsafe_tool":{"cliName":"run","serverOverride":"drive"} + }} + }} + }`) + writeManifest("unsafe-stdio", `{ + "name":"unsafe-stdio", + "version":"1.0.0", + "mcpServers":{"unsafe":{ + "type":"stdio", + "command":"bin/unsafe", + "cli":{"id":"unsafe-stdio-id","command":"unsafe-stdio","toolOverrides":{ + "unsafe_tool":{"cliName":"run","flags":{"value":{"mapsTo":"target"}}} + }} + }} + }`) + + root := pluginTestRoot() + if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 { + t.Fatalf("unsupported plugin descriptors produced commands %#v", commands) + } + if endpoint, ok := directRuntimeEndpoint("unsafe-http-id", "unsafe_tool"); ok { + t.Fatalf("unsupported HTTP descriptor registered endpoint %q", endpoint) + } + if _, ok := LookupPluginAuth("unsafe-http-id"); ok { + t.Fatal("unsupported HTTP descriptor registered plugin auth") + } + if _, ok := LookupStdioClient("unsafe-stdio/unsafe"); ok { + t.Fatal("unsupported stdio descriptor registered a client") + } +} diff --git a/internal/app/plugin_input_schema.go b/internal/app/plugin_input_schema.go new file mode 100644 index 00000000..a2858b67 --- /dev/null +++ b/internal/app/plugin_input_schema.go @@ -0,0 +1,239 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +func pluginToolInputSchema( + tools transport.ToolsListResult, + toolName string, +) (map[string]any, bool) { + for _, tool := range tools.Tools { + if strings.TrimSpace(tool.Name) == strings.TrimSpace(toolName) { + return tool.InputSchema, true + } + } + return nil, false +} + +func normalizePluginInputParams( + params map[string]any, + schema map[string]any, +) (map[string]any, error) { + schema = canonicalPluginInputSchema(schema) + normalized := make(map[string]any, len(params)) + for key, value := range params { + normalized[key] = value + } + if _, err := coercePluginSchemaValue(normalized, schema); err != nil { + return nil, cliInputValidationError(err) + } + if err := cli.ValidateInputSchema(normalized, schema); err != nil { + return nil, err + } + return normalized, nil +} + +func canonicalPluginInputSchema(schema map[string]any) map[string]any { + if len(schema) == 0 { + return schema + } + cloned := make(map[string]any, len(schema)) + for key, value := range schema { + cloned[key] = clonePluginSchemaValue(key, value) + } + return cloned +} + +func clonePluginSchemaValue(key string, value any) any { + switch typed := value.(type) { + case map[string]any: + cloned := make(map[string]any, len(typed)) + for childKey, childValue := range typed { + cloned[childKey] = clonePluginSchemaValue(childKey, childValue) + } + return cloned + case []any: + cloned := make([]any, len(typed)) + for index, item := range typed { + cloned[index] = clonePluginSchemaValue(key, item) + } + return cloned + case []string: + cloned := make([]string, len(typed)) + for index, item := range typed { + if key == "type" { + item = canonicalPluginSchemaType(item) + } + cloned[index] = item + } + return cloned + case string: + if key == "type" { + return canonicalPluginSchemaType(typed) + } + return typed + default: + return value + } +} + +func canonicalPluginSchemaType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "bool": + return "boolean" + case "int": + return "integer" + case "float": + return "number" + default: + return value + } +} + +func cliInputValidationError(err error) error { + if err == nil { + return nil + } + return apperrors.NewValidation( + fmt.Sprintf("input schema normalization failed: %v", err), + apperrors.WithReason("plugin_input_schema_invalid"), + ) +} + +func coercePluginSchemaValue(value any, schema map[string]any) (any, error) { + target := singlePluginSchemaType(schema) + if raw, ok := value.(string); ok { + trimmed := strings.TrimSpace(raw) + switch target { + case "bool", "boolean": + parsed, err := strconv.ParseBool(trimmed) + if err != nil { + return nil, fmt.Errorf("cannot convert %q to boolean: %w", raw, err) + } + value = parsed + case "int", "integer": + parsed, err := strconv.Atoi(trimmed) + if err != nil { + return nil, fmt.Errorf("cannot convert %q to integer: %w", raw, err) + } + value = parsed + case "float", "number": + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return nil, fmt.Errorf("cannot convert %q to number: %w", raw, err) + } + value = parsed + case "object": + var parsed map[string]any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil, fmt.Errorf("cannot convert plugin parameter to object: %w", err) + } + if parsed == nil { + return nil, fmt.Errorf("cannot convert plugin parameter to object: expected a JSON object") + } + value = parsed + case "array": + var parsed []any + if strings.HasPrefix(trimmed, "[") { + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil, fmt.Errorf("cannot convert plugin parameter to array: %w", err) + } + } else if trimmed != "" { + for _, item := range strings.Split(trimmed, ",") { + if item = strings.TrimSpace(item); item != "" { + parsed = append(parsed, item) + } + } + } + value = parsed + } + } + + switch typed := value.(type) { + case map[string]any: + properties, _ := schema["properties"].(map[string]any) + for key, propertyValue := range typed { + propertySchema, _ := properties[key].(map[string]any) + if len(propertySchema) == 0 { + continue + } + coerced, err := coercePluginSchemaValue(propertyValue, propertySchema) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + typed[key] = coerced + } + return typed, nil + case []string: + items := make([]any, len(typed)) + for index, item := range typed { + items[index] = item + } + value = items + } + + if items, ok := value.([]any); ok { + itemSchema, _ := schema["items"].(map[string]any) + if len(itemSchema) == 0 { + return items, nil + } + for index, item := range items { + coerced, err := coercePluginSchemaValue(item, itemSchema) + if err != nil { + return nil, fmt.Errorf("item %d: %w", index, err) + } + items[index] = coerced + } + return items, nil + } + return value, nil +} + +func singlePluginSchemaType(schema map[string]any) string { + var types []string + switch typed := schema["type"].(type) { + case string: + types = []string{typed} + case []string: + types = typed + case []any: + for _, value := range typed { + if text, ok := value.(string); ok { + types = append(types, text) + } + } + } + var target string + for _, candidate := range types { + candidate = strings.TrimSpace(candidate) + if candidate == "" || candidate == "null" { + continue + } + if target != "" && target != candidate { + return "" + } + target = candidate + } + return target +} diff --git a/internal/app/plugin_input_schema_coverage_test.go b/internal/app/plugin_input_schema_coverage_test.go new file mode 100644 index 00000000..a47a78d0 --- /dev/null +++ b/internal/app/plugin_input_schema_coverage_test.go @@ -0,0 +1,229 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "errors" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +func TestPluginToolInputSchemaMatchesTrimmedName(t *testing.T) { + want := map[string]any{"type": "object"} + tools := transport.ToolsListResult{Tools: []transport.ToolDescriptor{ + {Name: "other", InputSchema: map[string]any{"type": "string"}}, + {Name: " create_conference ", InputSchema: want}, + }} + + got, ok := pluginToolInputSchema(tools, " create_conference ") + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("pluginToolInputSchema() = (%#v, %v), want (%#v, true)", got, ok, want) + } + if got, ok := pluginToolInputSchema(tools, "missing"); ok || got != nil { + t.Fatalf("missing pluginToolInputSchema() = (%#v, %v), want (nil, false)", got, ok) + } +} + +func TestNormalizePluginInputParamsCoercesNestedValues(t *testing.T) { + schema := map[string]any{ + "type": "object", + "required": []string{"enabled"}, + "properties": map[string]any{ + "enabled": map[string]any{"type": []any{"null", "bool"}}, + "count": map[string]any{"type": "int"}, + "ratio": map[string]any{"type": "float"}, + "settings": map[string]any{ + "type": "object", + "properties": map[string]any{ + "active": map[string]any{"type": "bool"}, + }, + }, + "ids": map[string]any{ + "type": []string{"array", "null"}, + "items": map[string]any{"type": "int"}, + }, + "labels": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + "booleans": map[string]any{ + "type": "array", + "items": map[string]any{"type": "bool"}, + }, + "ambiguous": map[string]any{"type": []string{"string", "int"}}, + }, + } + params := map[string]any{ + "enabled": " true ", + "count": " 7 ", + "ratio": " 2.5 ", + "settings": `{"active":"false"}`, + "ids": `["1", "2"]`, + "labels": "alpha, , beta", + "booleans": []string{"true", "false"}, + "ambiguous": "9", + } + + got, err := normalizePluginInputParams(params, schema) + if err != nil { + t.Fatalf("normalizePluginInputParams() error = %v", err) + } + want := map[string]any{ + "enabled": true, + "count": 7, + "ratio": 2.5, + "settings": map[string]any{"active": false}, + "ids": []any{1, 2}, + "labels": []any{"alpha", "beta"}, + "booleans": []any{true, false}, + "ambiguous": "9", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizePluginInputParams() = %#v, want %#v", got, want) + } + + properties := schema["properties"].(map[string]any) + if gotType := properties["enabled"].(map[string]any)["type"].([]any)[1]; gotType != "bool" { + t.Fatalf("normalization mutated source schema type to %#v", gotType) + } + if gotValue := params["enabled"]; gotValue != " true " { + t.Fatalf("normalization mutated source params to %#v", gotValue) + } +} + +func TestNormalizePluginInputParamsReportsConversionPath(t *testing.T) { + tests := []struct { + name string + value any + fieldSchema map[string]any + wantText string + }{ + {name: "boolean", value: "sometimes", fieldSchema: map[string]any{"type": "bool"}, wantText: "cannot convert"}, + {name: "integer", value: "1.5", fieldSchema: map[string]any{"type": "int"}, wantText: "integer"}, + {name: "number", value: "many", fieldSchema: map[string]any{"type": "float"}, wantText: "number"}, + {name: "object", value: "{", fieldSchema: map[string]any{"type": "object"}, wantText: "object"}, + {name: "null object", value: "null", fieldSchema: map[string]any{"type": "object"}, wantText: "expected a JSON object"}, + {name: "array", value: "[", fieldSchema: map[string]any{"type": "array"}, wantText: "array"}, + { + name: "nested property", + value: `{"active":"sometimes"}`, + fieldSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "active": map[string]any{"type": "bool"}, + }, + }, + wantText: "field: active:", + }, + { + name: "array item", + value: "1,not-an-int", + fieldSchema: map[string]any{ + "type": "array", + "items": map[string]any{"type": "int"}, + }, + wantText: "item 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{"field": tt.fieldSchema}, + } + _, err := normalizePluginInputParams(map[string]any{"field": tt.value}, schema) + if err == nil { + t.Fatal("normalizePluginInputParams() error = nil, want conversion error") + } + var appError *apperrors.Error + if !errors.As(err, &appError) || + appError.Category != apperrors.CategoryValidation || + appError.Reason != "plugin_input_schema_invalid" { + t.Fatalf("conversion error = %#v, want categorized plugin schema validation error", err) + } + if !strings.Contains(err.Error(), tt.wantText) { + t.Fatalf("conversion error = %q, want text %q", err, tt.wantText) + } + }) + } +} + +func TestNormalizePluginInputParamsRunsSchemaValidation(t *testing.T) { + schema := map[string]any{ + "type": "object", + "required": []any{"name"}, + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + } + if _, err := normalizePluginInputParams(map[string]any{}, schema); err == nil || + !strings.Contains(err.Error(), "$.name is required") { + t.Fatalf("required-field validation error = %v", err) + } +} + +func TestPluginInputSchemaHelperEdges(t *testing.T) { + if got := canonicalPluginInputSchema(nil); got != nil { + t.Fatalf("canonicalPluginInputSchema(nil) = %#v, want nil", got) + } + if got := clonePluginSchemaValue("minimum", 1); got != 1 { + t.Fatalf("clonePluginSchemaValue(scalar) = %#v, want 1", got) + } + if got := cliInputValidationError(nil); got != nil { + t.Fatalf("cliInputValidationError(nil) = %v, want nil", got) + } + + if got, err := coercePluginSchemaValue("", map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, []any(nil)) { + t.Fatalf("empty array coercion = (%#v, %v), want nil slice", got, err) + } + items := []any{"unchanged"} + if got, err := coercePluginSchemaValue(items, map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, items) { + t.Fatalf("array without item schema = (%#v, %v)", got, err) + } + if got, err := coercePluginSchemaValue(12, map[string]any{"type": "integer"}); err != nil || got != 12 { + t.Fatalf("non-string scalar coercion = (%#v, %v), want (12, nil)", got, err) + } + unknown := map[string]any{"unknown": "unchanged"} + if got, err := coercePluginSchemaValue(unknown, map[string]any{ + "type": "object", + "properties": map[string]any{}, + }); err != nil || !reflect.DeepEqual(got, unknown) { + t.Fatalf("unknown property coercion = (%#v, %v), want unchanged map", got, err) + } + + tests := []struct { + name string + schema map[string]any + want string + }{ + {name: "missing", schema: map[string]any{}, want: ""}, + {name: "single string", schema: map[string]any{"type": "integer"}, want: "integer"}, + {name: "single string slice", schema: map[string]any{"type": []string{"null", "number"}}, want: "number"}, + {name: "any slice", schema: map[string]any{"type": []any{nil, 3, "", "null", "boolean"}}, want: "boolean"}, + {name: "ambiguous", schema: map[string]any{"type": []any{"string", "integer"}}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := singlePluginSchemaType(tt.schema); got != tt.want { + t.Fatalf("singlePluginSchemaType(%#v) = %q, want %q", tt.schema, got, tt.want) + } + }) + } + + for raw, want := range map[string]string{ + " BOOL ": "boolean", + "Int": "integer", + "FLOAT": "number", + "custom": "custom", + } { + if got := canonicalPluginSchemaType(raw); got != want { + t.Errorf("canonicalPluginSchemaType(%q) = %q, want %q", raw, got, want) + } + } +} diff --git a/internal/app/plugin_input_schema_test.go b/internal/app/plugin_input_schema_test.go new file mode 100644 index 00000000..6406d6f3 --- /dev/null +++ b/internal/app/plugin_input_schema_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "errors" + "reflect" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +func TestPluginStdioExecutionNormalizesAndValidatesLiveSchema(t *testing.T) { + isolatePluginRuntime(t) + previousInit := runnerStdioEnsureInitialized + previousList := runnerStdioListTools + previousCall := runnerStdioCallTool + t.Cleanup(func() { + runnerStdioEnsureInitialized = previousInit + runnerStdioListTools = previousList + runnerStdioCallTool = previousCall + }) + + client := transport.NewStdioClient("unused", nil, nil) + RegisterStdioClient("conference/local", client) + runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { + return nil + } + runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) { + return transport.ToolsListResult{ + Tools: []transport.ToolDescriptor{{ + Name: "create_conference", + InputSchema: map[string]any{ + "type": "object", + "required": []any{"title"}, + "properties": map[string]any{ + "title": map[string]any{"type": "string"}, + "capture_speaker": map[string]any{"type": "bool"}, + }, + "additionalProperties": false, + }, + }}, + }, nil + } + var calledParams map[string]any + runnerStdioCallTool = func( + _ *transport.StdioClient, + _ context.Context, + _ string, + params map[string]any, + ) (transport.ToolCallResult, error) { + calledParams = params + return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil + } + + runner := &runtimeRunner{} + invocation := executor.Invocation{ + CanonicalProduct: "conference-local", + Tool: "create_conference", + Params: map[string]any{ + "title": "schema validation", + "capture_speaker": "true", + }, + } + result, err := runner.executeInvocation( + context.Background(), + "stdio://conference/local", + invocation, + ) + if err != nil { + t.Fatalf("stdio plugin execution: %v", err) + } + wantParams := map[string]any{ + "title": "schema validation", + "capture_speaker": true, + } + if !reflect.DeepEqual(calledParams, wantParams) || + !reflect.DeepEqual(result.Invocation.Params, wantParams) { + t.Fatalf("normalized wire params = %#v, result = %#v", calledParams, result.Invocation.Params) + } + + calledParams = nil + invocation.Params = map[string]any{"capture_speaker": "true"} + _, err = runner.executeInvocation( + context.Background(), + "stdio://conference/local", + invocation, + ) + var appError *apperrors.Error + if !errors.As(err, &appError) || + appError.Category != apperrors.CategoryValidation || + calledParams != nil { + t.Fatalf("missing required schema validation = %#v, call params = %#v", err, calledParams) + } +} diff --git a/internal/app/plugin_registration_residual_test.go b/internal/app/plugin_registration_residual_test.go new file mode 100644 index 00000000..ffbf83ad --- /dev/null +++ b/internal/app/plugin_registration_residual_test.go @@ -0,0 +1,369 @@ +// 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 ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + 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/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef" + "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/mcptypes" + "github.com/spf13/cobra" +) + +type schemaSourceContextKey struct{} + +func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) { + previous := rootLoadPlugins + t.Cleanup(func() { rootLoadPlugins = previous }) + + pluginLoads := 0 + rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command { + pluginLoads++ + return nil + } + wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema") + root := NewSchemaSourceRootCommand(wantContext) + if root.Context() != wantContext { + t.Fatal("Schema source root did not retain the caller context") + } + if pluginLoads != 0 { + t.Fatalf("Schema source root loaded runtime plugins %d times", pluginLoads) + } +} + +func TestCollectPluginServerCandidatesSortsAndSkipsInvalidStdio(t *testing.T) { + previousDescriptors := rootPluginDescriptors + previousClients := rootPluginStdioClients + previousDescriptor := rootPluginStdioDescriptor + t.Cleanup(func() { + rootPluginDescriptors = previousDescriptors + rootPluginStdioClients = previousClients + rootPluginStdioDescriptor = previousDescriptor + }) + + first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "first"}} + second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "second"}} + wantContext := &plugin.UserContext{UserID: "user", CorpID: "corp"} + client := transport.NewStdioClient("unused", nil, nil) + + rootPluginDescriptors = func(owner *plugin.Plugin) []mcptypes.ServerDescriptor { + if owner == first { + return []mcptypes.ServerDescriptor{{Key: "same"}, {Key: " beta "}} + } + return []mcptypes.ServerDescriptor{{Key: "aardvark"}} + } + rootPluginStdioClients = func(owner *plugin.Plugin, gotContext *plugin.UserContext) []plugin.StdioServerClient { + if gotContext != wantContext { + t.Fatalf("stdio user context = %#v, want %#v", gotContext, wantContext) + } + if owner != first { + return nil + } + return []plugin.StdioServerClient{ + {Key: "same", Client: client}, + {Key: " alpha ", Client: client}, + {Key: "invalid", Client: client}, + } + } + rootPluginStdioDescriptor = func(_ *plugin.Plugin, stdio plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) { + if stdio.Key == "invalid" { + return mcptypes.ServerDescriptor{}, false + } + return mcptypes.ServerDescriptor{Key: stdio.Key}, true + } + + candidates := collectPluginServerCandidates([]*plugin.Plugin{first, second}, wantContext) + if len(candidates) != 5 { + t.Fatalf("candidate count = %d, want 5", len(candidates)) + } + gotKeys := make([]string, 0, len(candidates)) + gotKinds := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + gotKeys = append(gotKeys, candidate.descriptor.Key) + if candidate.stdioClient == nil { + gotKinds = append(gotKinds, "http") + } else { + gotKinds = append(gotKinds, "stdio") + if candidate.stdioClient.Client != client { + t.Fatal("stdio candidate did not retain its client") + } + } + } + if want := []string{" alpha ", " beta ", "same", "same", "aardvark"}; !reflect.DeepEqual(gotKeys, want) { + t.Fatalf("candidate keys = %#v, want %#v", gotKeys, want) + } + if want := []string{"stdio", "http", "http", "stdio", "http"}; !reflect.DeepEqual(gotKinds, want) { + t.Fatalf("candidate transports = %#v, want %#v", gotKinds, want) + } +} + +func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) { + isolatePluginRuntime(t) + + blank := mcptypes.ServerDescriptor{ + Key: " ", + CLI: mcptypes.CLIOverlay{ + ID: " ", + Command: " ", + Aliases: []string{"", " "}, + }, + } + if claims := pluginDescriptorIdentityClaims(blank); len(claims) != 0 { + t.Fatalf("blank descriptor claims = %#v, want none", claims) + } + if rootName := pluginDescriptorRootName(blank); rootName != "" { + t.Fatalf("blank descriptor root = %q", rootName) + } + owner := &plugin.Plugin{Manifest: plugin.Manifest{Name: "blank"}} + accepted := selectPluginServerCandidates( + &cobra.Command{Use: "dws"}, + []pluginServerCandidate{ + {owner: owner, descriptor: mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}}, + {owner: owner, descriptor: blank}, + }, + ) + if len(accepted) != 1 { + t.Fatalf("blank descriptor candidates = %#v, want one accepted candidate", accepted) + } + + if distributionRootOwns(nil, "visible") { + t.Fatal("nil root claimed a command") + } + root := &cobra.Command{Use: "dws"} + visible := &cobra.Command{Use: "visible", Aliases: []string{" visible-alias "}} + hiddenFallback := &cobra.Command{Use: "conference", Hidden: true} + hiddenOwned := &cobra.Command{Use: "hidden-owned", Hidden: true} + pluginOwned := &cobra.Command{Use: "plugin-owned", Aliases: []string{"plugin-alias"}} + cmdutil.MarkPluginSource(pluginOwned) + root.AddCommand(visible, hiddenFallback, hiddenOwned, pluginOwned) + + for _, name := range []string{"visible", "visible-alias", "hidden-owned"} { + if !distributionRootOwns(root, name) { + t.Errorf("distribution root did not claim %q", name) + } + } + for _, name := range []string{"conference", "plugin-owned", "plugin-alias", "missing"} { + if distributionRootOwns(root, name) { + t.Errorf("distribution root unexpectedly claimed %q", name) + } + } +} + +func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) { + isolatePluginRuntime(t) + SetDynamicServers([]mcptypes.ServerDescriptor{ + { + Key: "conference", + Endpoint: "https://example.com/conference/mcp", + CLI: mcptypes.CLIOverlay{ID: "conference"}, + }, + { + Key: "chat", + Endpoint: "https://example.com/chat/mcp", + CLI: mcptypes.CLIOverlay{ID: "chat"}, + }, + }) + + root := &cobra.Command{Use: "dws"} + root.AddCommand(&cobra.Command{Use: "conference", Hidden: true}) + distributionProducts := DirectRuntimeProductIDs() + + conferenceDescriptor := mcptypes.ServerDescriptor{ + Key: "conference-local", + DisplayName: "conference/conference-local", + CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"}, + } + if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) { + t.Fatal("replaceable fallback identity blocked plugin server selection") + } + + chatDescriptor := mcptypes.ServerDescriptor{ + Key: "chat-local", + DisplayName: "chat/chat-local", + CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"}, + } + if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) { + t.Fatal("non-replaceable distribution product no longer conflicts") + } + + reservedDescriptor := mcptypes.ServerDescriptor{ + Key: "auth-local", + DisplayName: "auth/auth-local", + CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"}, + } + if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) { + t.Fatal("reserved command name no longer conflicts") + } + + first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}} + second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}} + accepted := selectPluginServerCandidates(root, []pluginServerCandidate{ + {owner: first, descriptor: conferenceDescriptor}, + { + owner: second, + descriptor: mcptypes.ServerDescriptor{ + Key: "conference-other", + DisplayName: "other/conference-other", + CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"}, + }, + }, + }) + if len(accepted) != 1 { + t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted)) + } + if accepted[0].owner != first { + t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name) + } +} + +func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.AddCommand(&cobra.Command{Use: "taken"}) + command := &cobra.Command{ + Use: "extension", + Aliases: []string{"", "extension", "auth", "taken", "shared", " shared ", " okay "}, + } + addPluginCommandsSafe(root, []*cobra.Command{ + command, + {Use: "shared"}, + {Use: "other", Aliases: []string{"extension"}}, + }) + + if want := []string{"shared", "okay"}; !reflect.DeepEqual(command.Aliases, want) { + t.Fatalf("filtered aliases = %#v, want %#v", command.Aliases, want) + } + if child := findDirectChild(root, "shared"); child != nil { + t.Fatal("an accepted alias was also registered as a plugin primary command") + } + other := findDirectChild(root, "other") + if other == nil || len(other.Aliases) != 0 { + t.Fatalf("later plugin aliases = %#v", other) + } +} + +func TestStdioRunnerReportsToolsListFailureAndMissingTool(t *testing.T) { + isolatePluginRuntime(t) + previousInit := runnerStdioEnsureInitialized + previousList := runnerStdioListTools + previousCall := runnerStdioCallTool + t.Cleanup(func() { + runnerStdioEnsureInitialized = previousInit + runnerStdioListTools = previousList + runnerStdioCallTool = previousCall + }) + + client := transport.NewStdioClient("unused", nil, nil) + RegisterStdioClient("plugin/server", client) + runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil } + toolCalls := 0 + runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) { + toolCalls++ + return transport.ToolCallResult{}, nil + } + runner := &runtimeRunner{} + invocation := executor.Invocation{CanonicalProduct: "overlay-id", Tool: "wanted"} + + listFailure := errors.New("list failed") + runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) { + return transport.ToolsListResult{}, listFailure + } + _, err := runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation) + assertPluginRuntimeError(t, err, apperrors.CategoryAPI, "tools/list", "stdio_tools_list_error") + + runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) { + return transport.ToolsListResult{Tools: []transport.ToolDescriptor{{Name: "other"}}}, nil + } + _, err = runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation) + assertPluginRuntimeError(t, err, apperrors.CategoryValidation, "", "plugin_tool_not_found") + if toolCalls != 0 { + t.Fatalf("tools/call attempts after tools/list failures = %d", toolCalls) + } +} + +func TestStdioManifestDescriptorAndRegistrationFailClosed(t *testing.T) { + isolatePluginRuntime(t) + p := &plugin.Plugin{ + Manifest: plugin.Manifest{ + Name: "broken-plugin", + MCPServers: map[string]*plugin.MCPServer{ + "local": {CLI: json.RawMessage(`{`)}, + }, + }, + } + server := plugin.StdioServerClient{ + Key: "local", + Client: transport.NewStdioClient("unused", nil, nil), + } + if descriptor, ok := stdioServerDescriptorFromManifest(p, server); ok || !reflect.ValueOf(descriptor).IsZero() { + t.Fatalf("invalid descriptor = (%#v, %v), want zero, false", descriptor, ok) + } + if descriptor := registerStdioServerFromManifest(p, server); !reflect.ValueOf(descriptor).IsZero() { + t.Fatalf("invalid registered descriptor = %#v, want zero", descriptor) + } + if _, ok := LookupStdioClient("broken-plugin/local"); ok { + t.Fatal("invalid stdio manifest registered a client") + } +} + +func TestLegacyCommandsContinueWhenUserShortcutLoadFails(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + shortcutDir := filepath.Join(configDir, "shortcuts") + if err := os.MkdirAll(shortcutDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(shortcutDir, "broken.yaml"), []byte("version: ["), 0o600); err != nil { + t.Fatal(err) + } + if _, loadErrors := userdef.Load(); len(loadErrors) == 0 { + t.Fatal("malformed shortcut fixture did not fail to load") + } + + runner := executor.EchoRunner{} + caller := newToolCallerAdapter(runner, &GlobalFlags{}) + if commands := newLegacyPublicCommands(runner, caller, true); len(commands) == 0 { + t.Fatal("legacy commands were dropped after a user shortcut load error") + } +} + +func findDirectChild(root *cobra.Command, name string) *cobra.Command { + for _, command := range root.Commands() { + if command.Name() == name { + return command + } + } + return nil +} + +func assertPluginRuntimeError( + t *testing.T, + err error, + wantCategory apperrors.Category, + wantOperation string, + wantReason string, +) { + t.Helper() + var appError *apperrors.Error + if !errors.As(err, &appError) { + t.Fatalf("runtime error = %#v, want structured app error", err) + } + if appError.Category != wantCategory || + appError.Operation != wantOperation || + appError.Reason != wantReason { + t.Fatalf("runtime error = %#v, want category=%q operation=%q reason=%q", appError, wantCategory, wantOperation, wantReason) + } +} diff --git a/internal/app/plugin_registration_test.go b/internal/app/plugin_registration_test.go index 5b2154c8..7252663c 100644 --- a/internal/app/plugin_registration_test.go +++ b/internal/app/plugin_registration_test.go @@ -5,6 +5,7 @@ package app import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -12,6 +13,7 @@ import ( "sync/atomic" "testing" + "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/internal/transport" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" @@ -35,6 +37,11 @@ func isolatePluginRuntime(t *testing.T) { stdioClients = make(map[string]*transport.StdioClient) stdioMu.Unlock() + pluginAuthMu.Lock() + previousPluginAuth := pluginAuthRegistry + pluginAuthRegistry = make(map[string]*PluginAuth) + pluginAuthMu.Unlock() + t.Cleanup(func() { StopAllStdioClients() dynamicMu.Lock() @@ -46,6 +53,9 @@ func isolatePluginRuntime(t *testing.T) { stdioMu.Lock() stdioClients = previousStdio stdioMu.Unlock() + pluginAuthMu.Lock() + pluginAuthRegistry = previousPluginAuth + pluginAuthMu.Unlock() }) } @@ -77,14 +87,40 @@ func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) { func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) { isolatePluginRuntime(t) marker := t.TempDir() + "/started" + pluginRoot := t.TempDir() + if err := os.WriteFile(pluginRoot+"/overlay.json", []byte(`{ + "id":"local", + "command":"lazy-stdio", + "groups":{"health":{"description":"health checks"}}, + "toolOverrides":{"ping":{"cliName":"ping","group":"health"}} + }`), 0o600); err != nil { + t.Fatal(err) + } 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(), + Manifest: plugin.Manifest{ + Name: "lazy-stdio", + Description: "lazy stdio test", + MCPServers: map[string]*plugin.MCPServer{ + "local": { + Type: "stdio", + Command: "unused", + CLI: json.RawMessage(`"overlay.json"`), + }, + }, + }, + Root: pluginRoot, } descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client}) + commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil) + root := pluginTestRoot(commands...) + root.SetArgs([]string{"lazy-stdio", "--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("lazy stdio help: %v", err) + } + requirePluginChild(t, commands[0], "health", "ping") if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("stdio process started during registration: stat error = %v", err) diff --git a/internal/app/plugin_stdio_overlay.go b/internal/app/plugin_stdio_overlay.go index c2ab794c..8b395d70 100644 --- a/internal/app/plugin_stdio_overlay.go +++ b/internal/app/plugin_stdio_overlay.go @@ -14,10 +14,7 @@ package app import ( - "encoding/json" "log/slog" - "os" - "path/filepath" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" @@ -33,50 +30,26 @@ import ( // When no CLI metadata is present, a minimal overlay keyed by the server // name is returned so callers can still build an identity descriptor. func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay { - serverID := sc.Key - overlay := mcptypes.CLIOverlay{ - ID: serverID, - Command: serverID, - } - srv, ok := p.Manifest.MCPServers[sc.Key] - if !ok || len(srv.CLI) == 0 { - return overlay - } - - cliData := srv.CLI - // A JSON string is interpreted as a relative path to an external - // overlay file (e.g. "overlay.json") anchored at the plugin root. - if len(cliData) > 0 && cliData[0] == '"' { - var cliPath string - if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" { - absPath := filepath.Join(p.Root, cliPath) - if fileData, readErr := os.ReadFile(absPath); readErr == nil { - cliData = fileData - } else { - slog.Warn("plugin: failed to read CLI overlay file", - "plugin", p.Manifest.Name, "path", absPath, "error", readErr) - } + overlay, ok := p.ResolveCLIOverlay(sc.Key) + if !ok { + return mcptypes.CLIOverlay{ + ID: sc.Key, + Command: sc.Key, + Skip: true, } } - if err := json.Unmarshal(cliData, &overlay); err != nil { - slog.Warn("plugin: failed to parse CLI overlay for stdio server", - "plugin", p.Manifest.Name, "server", sc.Key, "error", err) - } - if overlay.ID == "" { - overlay.ID = serverID - } - if overlay.Command == "" { - overlay.Command = serverID - } return overlay } -// 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) - descriptor := mcptypes.ServerDescriptor{ +func stdioServerDescriptorFromManifest( + p *plugin.Plugin, + sc plugin.StdioServerClient, +) (mcptypes.ServerDescriptor, bool) { + overlay, ok := p.ResolveCLIOverlay(sc.Key) + if !ok { + return mcptypes.ServerDescriptor{}, false + } + return mcptypes.ServerDescriptor{ Key: sc.Key, DisplayName: p.Manifest.Name + "/" + sc.Key, Description: p.Manifest.Description, @@ -84,13 +57,30 @@ func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClie Source: "plugin", CLI: overlay, HasCLIMeta: true, - } + }, true +} +func registerResolvedStdioServer( + p *plugin.Plugin, + sc plugin.StdioServerClient, + descriptor mcptypes.ServerDescriptor, +) { AppendDynamicServer(descriptor) RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client) slog.Debug("plugin: stdio server registered from manifest", "plugin", p.Manifest.Name, "server", sc.Key, - "toolOverrides", len(overlay.ToolOverrides)) + "toolOverrides", len(descriptor.CLI.ToolOverrides)) +} + +// 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 { + descriptor, ok := stdioServerDescriptorFromManifest(p, sc) + if !ok { + return mcptypes.ServerDescriptor{} + } + registerResolvedStdioServer(p, sc, descriptor) return descriptor } diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go index 4f1f2989..b67d07e0 100644 --- a/internal/app/recovery_command.go +++ b/internal/app/recovery_command.go @@ -333,7 +333,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName if err != nil { return nil, err } - tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders()) + authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)) + if err != nil { + return nil, tokenResolutionError(err) + } + tc := r.transport.WithAuth(authToken, resolveIdentityHeaders()) result, err := tc.CallTool(ctx, endpoint, toolName, args) if err != nil { return nil, err diff --git a/internal/app/root.go b/internal/app/root.go index ef99c21b..a51a24f5 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -23,6 +23,7 @@ import ( "os" "os/signal" "path/filepath" + "sort" "strings" "sync" "syscall" @@ -69,7 +70,8 @@ var ( rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors rootPluginStdioClients = (*plugin.Plugin).StdioClients rootRegisterPluginHTTPServer = registerPluginHTTPServer - rootRegisterStdioManifest = registerStdioServerFromManifest + rootPluginStdioDescriptor = stdioServerDescriptorFromManifest + rootRegisterResolvedStdioServer = registerResolvedStdioServer rootPluginLoadHooks = (*plugin.Plugin).LoadHooks rootPluginSyncSkills = plugin.SyncSkills rootAuthLoadTokenData = authpkg.LoadTokenData @@ -306,13 +308,28 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command { if len(ctx) > 0 && ctx[0] != nil { rootCtx = ctx[0] } - return NewRootCommandWithEngine(rootCtx, nil) + return newRootCommandWithEngine(rootCtx, nil, true) +} + +// NewSchemaSourceRootCommand constructs the distribution-owned command tree +// used by Schema generation and command-surface policy. Installed plugins and +// user-defined shortcuts must not change the reviewed embedded Schema. +func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command { + var rootCtx context.Context + if len(ctx) > 0 && ctx[0] != nil { + rootCtx = ctx[0] + } + return newRootCommandWithEngine(rootCtx, nil, false) } // NewRootCommandWithEngine constructs the root CLI command with an // optional pipeline engine for input correction. When engine is nil, // no pipeline processing is applied. func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command { + return newRootCommandWithEngine(rootCtx, engine, true) +} + +func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command { if rootCtx == nil { rootCtx = context.Background() } @@ -368,11 +385,16 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) schemaCmd := newSchemaCommand(loader) mcpCmd := newMCPCommand(rootCtx, loader, runner, engine) - mcpCmd.Hidden = true + // The legacy dynamic MCP surface remains disabled, but reviewed static MCP + // helpers registered below are part of the public CLI and Schema surface. + mcpCmd.Hidden = false + mcpCmd.Short = "管理 MCP 服务连接信息" + mcpCmd.Long = "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。" // Wrap the caller so every MCP tool call's shape is recorded to the local // usage log (privacy-preserving; see internal/shortcut/usage). Powers // `dws shortcut stats` and future high-frequency shortcut distillation. patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags)) + mcpCmd.AddCommand(newMCPURLGroup(patCaller)) utilityCommands := []*cobra.Command{ newAuthCommand(patCaller), @@ -396,16 +418,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) } root.AddCommand(utilityCommands...) - root.AddCommand(newLegacyPublicCommands(runner, patCaller)...) + root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...) root.AddCommand(newLegacyHiddenCommands(runner)...) - // --- Plugin loading: runs AFTER legacy commands so plugin endpoints can - // be appended on top of the static endpoint registry. - pluginCmds := rootLoadPlugins(engine, runner) - if len(pluginCmds) > 0 { - addPluginCommandsSafe(root, pluginCmds) - } - // PAT authorization commands (open-source core) pat.RegisterCommands(root, patCaller) @@ -414,6 +429,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) fn(root, caller) deduplicateCommands(root) } + if loadRuntimeExtensions { + // Resolve plugins only after the complete distribution command tree is + // present, so endpoint and Cobra conflict checks see PAT and edition + // commands as well as the open-source base. + pluginCmds := rootLoadPlugins(root, engine, runner) + if len(pluginCmds) > 0 { + addPluginCommandsSafe(root, pluginCmds) + } + } hideNonDirectRuntimeCommands(root) configureRootHelp(root) // Set custom flag error handler for better UX @@ -631,12 +655,17 @@ var reservedCommands = map[string]bool{ "schema": true, "mcp": true, "help": true, } +var replaceablePluginFallbacks = map[string]bool{ + "conference": true, +} + // addPluginCommandsSafe registers plugin commands with conflict detection. // // Rules: // - Plugin vs reserved (auth/plugin/cache/...) → reject, warn // - Plugin vs plugin (same name) → reject later one, warn -// - Plugin vs Market dynamic command → allow, plugin wins +// - Plugin vs hidden compatibility fallback → allow, plugin wins +// - Plugin vs visible distribution command → reject, warn func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) { // Build index of existing commands before plugin registration. existing := make(map[string]bool) @@ -664,17 +693,47 @@ func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) { } pluginSeen[name] = true - // Rule 3: plugin vs Market — plugin wins, remove the old one. + // An alias must not bypass the same protections applied to primary + // plugin command names or shadow another root command. + filteredAliases := make([]string, 0, len(cmd.Aliases)) + for _, rawAlias := range cmd.Aliases { + alias := strings.TrimSpace(rawAlias) + if alias == "" || alias == name || reservedCommands[alias] || + existing[alias] || pluginSeen[alias] { + if alias != "" { + slog.Warn("plugin: command alias conflicts with an existing command, skipping", + "command", name, "alias", alias) + } + continue + } + pluginSeen[alias] = true + filteredAliases = append(filteredAliases, alias) + } + cmd.Aliases = filteredAliases + + // Rule 3: an installed plugin may replace a hidden compatibility + // fallback (for example conference), but never a visible distribution + // command that participates in the reviewed base interface. if existing[name] { for _, old := range root.Commands() { if old.Name() == name { + if !old.Hidden || !replaceablePluginFallbacks[name] || + cmdutil.IsPluginSourced(old) { + slog.Warn("plugin: command conflicts with a visible distribution command, skipping", + "command", name) + cmd = nil + break + } root.RemoveCommand(old) - slog.Debug("plugin: overriding Market command", + slog.Debug("plugin: overriding hidden compatibility command", "command", name) break } } } + if cmd == nil { + continue + } root.AddCommand(cmd) } @@ -811,7 +870,21 @@ func CloseFileLogger() { // 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 { +type pluginServerCandidate struct { + owner *plugin.Plugin + order int + descriptor mcptypes.ServerDescriptor + stdioClient *plugin.StdioServerClient +} + +type pluginIdentityOwner struct { + plugin *plugin.Plugin + serverKey string + rootName string + shareable bool +} + +func loadPlugins(root *cobra.Command, engine *pipeline.Engine, runner executor.Runner) []*cobra.Command { pluginLoader := plugin.NewLoader(RawVersion()) // 0a. Inject plugin config values from settings.json as environment @@ -838,25 +911,34 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command { // 2. Load dev plugins (registered via `dws plugin dev`) devPlugins := rootPluginLoadDev(pluginLoader) + sortPluginsForRegistration(userPlugins) + sortPluginsForRegistration(devPlugins) allPlugins := append(userPlugins, devPlugins...) + descriptorsByPlugin := make(map[*plugin.Plugin][]mcptypes.ServerDescriptor, len(allPlugins)) - // 3. Register HTTP descriptors and authentication from the manifest. - for _, p := range allPlugins { - for _, srv := range rootPluginDescriptors(p) { - rootRegisterPluginHTTPServer(srv) + // 3. Resolve every descriptor once, then choose identity winners before + // mutating endpoint, auth, or stdio-client registries. This keeps the + // visible command and its transport owned by the same plugin. + candidates := collectPluginServerCandidates(allPlugins, userCtx) + accepted := selectPluginServerCandidates(root, candidates) + for _, candidate := range accepted { + if candidate.stdioClient != nil { + rootRegisterResolvedStdioServer( + candidate.owner, + *candidate.stdioClient, + candidate.descriptor, + ) + } else { + rootRegisterPluginHTTPServer(candidate.descriptor) } + descriptorsByPlugin[candidate.owner] = append( + descriptorsByPlugin[candidate.owner], + candidate.descriptor, + ) } - // 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 rootPluginStdioClients(p, userCtx) { - rootRegisterStdioManifest(p, sc) - } - } - - // 5. Register plugin hooks into pipeline engine + // 4. Register plugin hooks into pipeline engine if engine != nil { for _, p := range allPlugins { hooksCfg, err := rootPluginLoadHooks(p) @@ -874,7 +956,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command { } } - // 7. Sync plugin skills to agent directories + // 5. Sync plugin skills to agent directories rootPluginSyncSkills(allPlugins) if len(allPlugins) > 0 { @@ -884,11 +966,228 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command { ) } - return nil + var pluginCommands []*cobra.Command + for _, p := range allPlugins { + // Build each plugin independently. addPluginCommandsSafe deliberately + // resolves cross-plugin root conflicts with first-plugin-wins semantics. + pluginCommands = append(pluginCommands, buildPluginCommands(descriptorsByPlugin[p], runner, root)...) + } + return pluginCommands +} + +func sortPluginsForRegistration(plugins []*plugin.Plugin) { + sort.SliceStable(plugins, func(i, j int) bool { + left := strings.TrimSpace(plugins[i].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[i].Root) + right := strings.TrimSpace(plugins[j].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[j].Root) + return left < right + }) +} + +func collectPluginServerCandidates( + plugins []*plugin.Plugin, + userCtx *plugin.UserContext, +) []pluginServerCandidate { + var candidates []pluginServerCandidate + for order, owner := range plugins { + for _, descriptor := range rootPluginDescriptors(owner) { + candidates = append(candidates, pluginServerCandidate{ + owner: owner, + order: order, + descriptor: descriptor, + }) + } + for _, stdioClient := range rootPluginStdioClients(owner, userCtx) { + descriptor, ok := rootPluginStdioDescriptor(owner, stdioClient) + if !ok { + continue + } + clientCopy := stdioClient + candidates = append(candidates, pluginServerCandidate{ + owner: owner, + order: order, + descriptor: descriptor, + stdioClient: &clientCopy, + }) + } + } + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].order != candidates[j].order { + return candidates[i].order < candidates[j].order + } + left := strings.TrimSpace(candidates[i].descriptor.Key) + right := strings.TrimSpace(candidates[j].descriptor.Key) + if left != right { + return left < right + } + return candidates[i].stdioClient == nil && candidates[j].stdioClient != nil + }) + return candidates +} + +func selectPluginServerCandidates( + root *cobra.Command, + candidates []pluginServerCandidate, +) []pluginServerCandidate { + distributionProducts := DirectRuntimeProductIDs() + owners := make(map[string]pluginIdentityOwner) + for identity := range distributionProducts { + if replaceablePluginFallbacks[identity] { + continue + } + owners[identity] = pluginIdentityOwner{serverKey: "distribution"} + } + + accepted := make([]pluginServerCandidate, 0, len(candidates)) + for _, candidate := range candidates { + descriptor := candidate.descriptor + if descriptor.CLI.Skip { + continue + } + if reason := unsupportedPluginDescriptor(root, descriptor); reason != "" { + slog.Warn("plugin: descriptor CLI semantics are unsupported, skipping", + "plugin", candidate.owner.Manifest.Name, + "server", descriptor.Key, + "field", reason) + continue + } + if pluginDescriptorConflictsWithDistribution(root, descriptor, distributionProducts) { + continue + } + claims := pluginDescriptorIdentityClaims(descriptor) + conflict := "" + for identity, shareable := range claims { + existing, exists := owners[identity] + if !exists { + continue + } + rootName := pluginDescriptorRootName(descriptor) + if shareable && existing.shareable && + existing.plugin == candidate.owner && + existing.rootName == rootName { + continue + } + conflict = identity + break + } + if conflict != "" { + slog.Warn("plugin: descriptor identity already owned, skipping", + "plugin", candidate.owner.Manifest.Name, + "server", descriptor.Key, + "identity", conflict) + continue + } + rootName := pluginDescriptorRootName(descriptor) + for identity, shareable := range claims { + if existing, exists := owners[identity]; exists && + shareable && existing.shareable && + existing.plugin == candidate.owner && + existing.rootName == rootName { + continue + } + owners[identity] = pluginIdentityOwner{ + plugin: candidate.owner, + serverKey: descriptor.Key, + rootName: rootName, + shareable: shareable, + } + } + accepted = append(accepted, candidate) + } + return accepted +} + +func pluginDescriptorIdentityClaims(descriptor mcptypes.ServerDescriptor) map[string]bool { + claims := make(map[string]bool) + canonicalID := firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key) + if canonicalID != "" { + claims[canonicalID] = false + } + for _, identity := range append( + []string{pluginDescriptorRootName(descriptor)}, + descriptor.CLI.Aliases..., + ) { + identity = strings.TrimSpace(identity) + if identity == "" { + continue + } + if _, exists := claims[identity]; !exists { + claims[identity] = true + } + } + return claims +} + +func pluginDescriptorRootName(descriptor mcptypes.ServerDescriptor) string { + return firstNonEmptyPluginString( + descriptor.CLI.Command, + descriptor.CLI.ID, + descriptor.Key, + ) +} + +func pluginDescriptorConflictsWithDistribution( + root *cobra.Command, + descriptor mcptypes.ServerDescriptor, + distributionProducts map[string]bool, +) bool { + candidates := append( + []string{ + firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key), + pluginDescriptorRootName(descriptor), + }, + descriptor.CLI.Aliases..., + ) + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + if !reservedCommands[candidate] && replaceablePluginFallbacks[candidate] { + // The distribution ships only a hidden compatibility fallback for + // this name; plugins may claim it and the later command merge in + // addPluginCommandsSafe still rejects visible non-fallback owners. + continue + } + if reservedCommands[candidate] || + distributionProducts[candidate] || + distributionRootOwns(root, candidate) { + slog.Warn("plugin: descriptor conflicts with a distribution command, skipping", + "plugin", descriptor.DisplayName, + "server", descriptor.Key, + "identity", candidate) + return true + } + } + return false +} + +func distributionRootOwns(root *cobra.Command, name string) bool { + if root == nil { + return false + } + for _, command := range root.Commands() { + if cmdutil.IsPluginSourced(command) { + continue + } + if command.Name() == name { + if command.Hidden && replaceablePluginFallbacks[name] { + return false + } + return true + } + for _, alias := range command.Aliases { + if strings.TrimSpace(alias) == name { + return true + } + } + } + return false } func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) { AppendDynamicServer(srv) + productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key) + ClearPluginAuth(productID) if len(srv.AuthHeaders) > 0 { registerPluginAuthFromHeaders(srv) } @@ -917,10 +1216,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) { host := parsed.Hostname() trustedDomains = []string{host, "*." + host} } - productID := strings.TrimSpace(srv.CLI.ID) - if productID == "" { - productID = srv.Key - } + productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key) RegisterPluginAuth(productID, &PluginAuth{ Token: authToken, ExtraHeaders: extraHeaders, diff --git a/internal/app/root_full_coverage_test.go b/internal/app/root_full_coverage_test.go index 26033cb9..4876059e 100644 --- a/internal/app/root_full_coverage_test.go +++ b/internal/app/root_full_coverage_test.go @@ -75,7 +75,7 @@ func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit }) - rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command { + rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command { return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}} } preRunCalled := false @@ -236,7 +236,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) { oldDescriptors := rootPluginDescriptors oldStdioClients := rootPluginStdioClients oldHTTP := rootRegisterPluginHTTPServer - oldStdio := rootRegisterStdioManifest + oldStdioDescriptor := rootPluginStdioDescriptor + oldStdioRegister := rootRegisterResolvedStdioServer oldHooks := rootPluginLoadHooks oldSync := rootPluginSyncSkills oldToken := rootAuthLoadTokenData @@ -247,7 +248,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) { rootPluginDescriptors = oldDescriptors rootPluginStdioClients = oldStdioClients rootRegisterPluginHTTPServer = oldHTTP - rootRegisterStdioManifest = oldStdio + rootPluginStdioDescriptor = oldStdioDescriptor + rootRegisterResolvedStdioServer = oldStdioRegister rootPluginLoadHooks = oldHooks rootPluginSyncSkills = oldSync rootAuthLoadTokenData = oldToken @@ -264,9 +266,17 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) { } rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor { if p == p1 { - return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}} + return []mcptypes.ServerDescriptor{{ + Key: "http", Endpoint: "https://example.test", + CLI: mcptypes.CLIOverlay{ + ID: "http", Command: "one-http", + ToolOverrides: map[string]mcptypes.CLIToolOverride{ + "ping": {CLIName: "ping"}, + }, + }, + }} } - return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}} + return []mcptypes.ServerDescriptor{{Key: p.Manifest.Name + "-no-cli", Endpoint: "https://example.test"}} } client := transport.NewStdioClient("ignored", nil, nil) rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient { @@ -278,9 +288,23 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) { httpCount := 0 stdioCount := 0 rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ } - rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor { + rootPluginStdioDescriptor = func(*plugin.Plugin, plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) { + return mcptypes.ServerDescriptor{ + Key: "local", + CLI: mcptypes.CLIOverlay{ + ID: "local", Command: "one-stdio", + ToolOverrides: map[string]mcptypes.CLIToolOverride{ + "pong": {CLIName: "pong"}, + }, + }, + }, true + } + rootRegisterResolvedStdioServer = func( + *plugin.Plugin, + plugin.StdioServerClient, + mcptypes.ServerDescriptor, + ) { stdioCount++ - return mcptypes.ServerDescriptor{} } rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) { switch p { @@ -294,7 +318,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) { } synced := false rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true } - if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil { + got := loadPlugins(nil, pipeline.NewEngine(), runnerCoverageFallback{}) + if len(got) != 2 || got[0].Name() != "one-http" || got[1].Name() != "one-stdio" { t.Fatalf("loaded plugin commands = %#v", got) } if httpCount != 3 || stdioCount != 1 || !synced { diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index d83f31be..22ff73fe 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -15,11 +15,14 @@ package app import ( "bytes" + stderrors "errors" "io" "os" + "path/filepath" "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -75,7 +78,14 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) { } } - mediaUpload := mustFindCommand(t, root, "chat", "media", "upload") + mediaGroup := mustFindCommand(t, root, "chat", "media") + if mediaGroup.Deprecated == "" || mediaGroup.Hidden || !mediaGroup.Runnable() { + t.Fatalf("chat media compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaGroup.Deprecated, mediaGroup.Hidden, mediaGroup.Runnable()) + } + mediaUpload := mustFindCommand(t, mediaGroup, "upload") + if mediaUpload.Deprecated == "" || mediaUpload.Hidden || !mediaUpload.Runnable() { + t.Fatalf("chat media upload compatibility contract: deprecated=%q hidden=%v runnable=%v", mediaUpload.Deprecated, mediaUpload.Hidden, mediaUpload.Runnable()) + } for _, flag := range []string{"file", "type"} { if mediaUpload.Flags().Lookup(flag) == nil { t.Fatalf("chat media upload missing --%s", flag) @@ -88,6 +98,113 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) { mustFindCommand(t, root, "conference", "meeting", "reserve") } +func TestChatHelpAndSchemaHideRetiredMediaUpload(t *testing.T) { + for _, args := range [][]string{ + {"chat", "--help"}, + {"chat", "media", "--help"}, + } { + root := NewRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("dws %s: %v\n%s", strings.Join(args, " "), err, output.String()) + } + for _, line := range strings.Split(output.String(), "\n") { + fields := strings.Fields(line) + if len(fields) > 0 && (fields[0] == "media" || fields[0] == "upload") { + t.Fatalf("dws %s exposes retired command in Help line %q:\n%s", strings.Join(args, " "), line, output.String()) + } + } + } + + root := NewRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"schema", "--cli-path", "chat media upload", "--format", "json"}) + err := root.Execute() + if err == nil { + t.Fatalf("retired chat media upload remains queryable from Schema:\n%s", output.String()) + } + if !strings.Contains(err.Error(), "unknown runtime schema path") { + t.Fatalf("retired chat media upload Schema error = %v, want unknown path", err) + } +} + +func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *testing.T) { + for _, key := range []string{"DWS_CLIENT_ID", "DWS_CLIENT_SECRET"} { + value, existed := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + t.Cleanup(func() { + if existed { + _ = os.Setenv(key, value) + return + } + _ = os.Unsetenv(key) + }) + if _, exists := os.LookupEnv(key); exists { + t.Fatalf("%s is still set", key) + } + } + t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) + + filePath := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(filePath, []byte("image"), 0o600); err != nil { + t.Fatalf("write image fixture: %v", err) + } + commandArgs := []string{ + "chat", "media", "upload", + "--file", filePath, + "--type", "image", + } + previousArgs := os.Args + os.Args = append([]string{"dws"}, commandArgs...) + t.Cleanup(func() { os.Args = previousArgs }) + + root := NewRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs(commandArgs) + err := root.Execute() + if err == nil { + t.Fatalf("chat media upload succeeded without app credentials:\n%s", output.String()) + } + + var typed *apperrors.Error + if !stderrors.As(err, &typed) { + t.Fatalf("chat media upload error type = %T, want *errors.Error: %v", err, err) + } + if typed.Category != apperrors.CategoryValidation { + t.Fatalf("chat media upload category = %q, want %q", typed.Category, apperrors.CategoryValidation) + } + if exitCode := apperrors.ExitCode(err); exitCode != 3 { + t.Fatalf("chat media upload exit code = %d, want 3", exitCode) + } + + got := output.String() + "\n" + err.Error() + for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} { + if !strings.Contains(got, want) { + t.Fatalf("chat media upload migration output missing %q:\n%s", want, got) + } + } + for _, forbidden := range []string{ + "DWS_CLIENT_ID", + "DWS_CLIENT_SECRET", + "缺少应用凭证", + "AppSecret", + "clientSecret", + } { + if strings.Contains(got, forbidden) { + t.Fatalf("chat media upload returned credential error %q:\n%s", forbidden, got) + } + } +} + func TestRootKeepsContactWukongCompatibilityCommands(t *testing.T) { root := NewRootCommand() label := mustFindCommand(t, root, "contact", "label") diff --git a/internal/app/runner.go b/internal/app/runner.go index 5437e642..6aee2a6a 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -168,6 +168,7 @@ var ( runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload runnerCallTool = (*transport.Client).CallTool runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized + runnerStdioListTools = (*transport.StdioClient).ListTools runnerStdioCallTool = (*transport.StdioClient).CallTool runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error) runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error) @@ -235,7 +236,9 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc // ~70ms on macOS; starting it here lets the load overlap with endpoint // resolution and catalog loading below. if prefetchToken { - go runnerGetCachedRuntimeToken(ctx) + go func() { + _, _ = runnerGetCachedRuntimeToken(ctx) + }() } if shouldUseDirectRuntime(invocation) { @@ -483,7 +486,7 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) { // Route stdio:// endpoints to the local StdioClient — no HTTP, no auth. if IsStdioEndpoint(endpoint) { - return r.executeStdioInvocation(ctx, invocation) + return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation) } // Constructing the Cobra tree is also used for help, schema, and command @@ -534,8 +537,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, authToken := "" if hasPluginAuth { authToken = pluginAuth.Token - } else { - authToken = r.resolveAuthToken(ctx) + } else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) { + var tokenErr error + authToken, tokenErr = r.resolveAuthToken(ctx) + if tokenErr != nil { + return executor.Result{}, tokenResolutionError(tokenErr) + } } var timeoutSec int @@ -617,6 +624,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, } return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, err, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, err, retryErr) + } + return result, retryErr + } runnerCaptureRuntimeFailure(invocation, err, err) return executor.Result{}, err } @@ -625,9 +638,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params) RecordTiming(ctx, "mcp_call", time.Since(callStart)) if err != nil { - if isAuthError(err) { + if isRefreshableTransportAuthError(err) { if fn := edition.Get().OnAuthError; fn != nil { if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil { + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, overrideErr, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, err, retryErr) + } + return result, retryErr + } runnerCaptureRuntimeFailure(invocation, err, overrideErr) return executor.Result{}, overrideErr } @@ -652,6 +671,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, } return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, editionErr, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, editionErr, retryErr) + } + return result, retryErr + } return executor.Result{}, editionErr } } @@ -672,6 +697,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, // patterns (PAT permission, gateway-auth) before generic handling. if classify := edition.Get().ClassifyToolResult; classify != nil { if hookErr := classify(callResult.Content); hookErr != nil { + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, hookErr, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, hookErr, retryErr) + } + return result, retryErr + } runnerCaptureRuntimeFailure(invocation, hookErr, hookErr) return executor.Result{}, hookErr } @@ -736,6 +767,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, // subprocess instead of the HTTP transport. This is used for plugin stdio // servers whose endpoints use the stdio:// scheme. func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { + return r.executeStdioInvocationAtEndpoint(ctx, "", invocation) +} + +func (r *runtimeRunner) executeStdioInvocationAtEndpoint( + ctx context.Context, + endpoint string, + invocation executor.Invocation, +) (executor.Result, error) { if invocation.DryRun { return executor.Result{ Invocation: invocation, @@ -748,10 +787,14 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e }, nil } - client, ok := LookupStdioClient(invocation.CanonicalProduct) + lookupKey := strings.Trim(strings.TrimPrefix(strings.TrimSpace(endpoint), stdioEndpointScheme), "/") + if lookupKey == "" { + lookupKey = invocation.CanonicalProduct + } + client, ok := LookupStdioClient(lookupKey) if !ok { return executor.Result{}, apperrors.NewInternal( - fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct)) + fmt.Sprintf("stdio client not found for %q", lookupKey)) } callCtx := ctx @@ -768,6 +811,27 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e ) } + tools, err := runnerStdioListTools(client, callCtx) + if err != nil { + return executor.Result{}, apperrors.NewAPI( + fmt.Sprintf("stdio tools/list failed: %v", err), + apperrors.WithOperation("tools/list"), + apperrors.WithReason("stdio_tools_list_error"), + ) + } + schema, ok := pluginToolInputSchema(tools, invocation.Tool) + if !ok { + return executor.Result{}, apperrors.NewValidation( + fmt.Sprintf("plugin tool %q is not declared by tools/list", invocation.Tool), + apperrors.WithReason("plugin_tool_not_found"), + ) + } + normalizedParams, err := normalizePluginInputParams(invocation.Params, schema) + if err != nil { + return executor.Result{}, err + } + invocation.Params = normalizedParams + callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params) if err != nil { return executor.Result{}, apperrors.NewAPI( @@ -796,67 +860,49 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e }, nil } -func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string { +func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) { explicitToken := "" if r != nil && r.globalFlags != nil { explicitToken = r.globalFlags.Token } - if token := strings.TrimSpace(explicitToken); token != "" { - return token - } - if tp := edition.Get().TokenProvider; tp != nil { - token, _ := tp(ctx, func() (string, error) { - return resolveAccessTokenFromDir(ctx, defaultConfigDir()) - }) - return token - } - return getCachedRuntimeToken(ctx) + return resolveRuntimeAuthToken(ctx, explicitToken) } -func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string { - if token := strings.TrimSpace(explicitToken); token != "" { - return token +func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) { + snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken) + if err != nil { + return "", err } - // Use cached token to avoid repeated Keychain access (~70ms per call) - return getCachedRuntimeToken(ctx) + return snapshot.AccessToken, nil } -// Cached token state for process lifetime -var ( - cachedRuntimeTokenMu sync.Mutex - cachedRuntimeTokens = map[string]string{} -) - -// getCachedRuntimeToken returns a cached access token, loading it only once per process. -// This avoids repeated Keychain access which takes ~70ms each time. -func getCachedRuntimeToken(ctx context.Context) string { - cacheKey := strings.TrimSpace(authpkg.RuntimeProfile()) - if cacheKey == "" { - cacheKey = "__default__" - } - cachedRuntimeTokenMu.Lock() - if token := cachedRuntimeTokens[cacheKey]; token != "" { - cachedRuntimeTokenMu.Unlock() - return token - } - cachedRuntimeTokenMu.Unlock() - +// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The +// cache itself lives exclusively in TokenManager. +func getCachedRuntimeToken(ctx context.Context) (string, error) { loadStart := time.Now() defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }() + return resolveRuntimeAuthToken(ctx, "") +} - configDir := defaultConfigDir() - token, tokenErr := resolveAccessTokenFromDir(ctx, configDir) - if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { - slog.Error(tokenErr.Error()) - return "" +func tokenResolutionError(err error) error { + if err == nil { + return nil } - if token == "" { - return "" + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err } - cachedRuntimeTokenMu.Lock() - cachedRuntimeTokens[cacheKey] = token - cachedRuntimeTokenMu.Unlock() - return token + if errors.Is(err, authpkg.ErrTokenDataNotFound) { + return apperrors.NewAuth( + "未登录,请先执行 dws auth login", + apperrors.WithReason("not_authenticated"), + apperrors.WithHint("运行 'dws auth login' 完成登录后重试"), + apperrors.WithActions("dws auth login"), + apperrors.WithCause(err), + ) + } + // Keychain, parse, permission, lock, and refresh failures are real local or + // network errors. Preserve their cause instead of disguising them as logout. + return fmt.Errorf("resolve access token: %w", err) } // generateExecutionID returns a random 16-char hex string used to correlate @@ -871,9 +917,7 @@ func generateExecutionID() string { // ResetRuntimeTokenCache clears the cached token, forcing a reload on next access. // This should be called after login/logout operations. func ResetRuntimeTokenCache() { - cachedRuntimeTokenMu.Lock() - defer cachedRuntimeTokenMu.Unlock() - cachedRuntimeTokens = map[string]string{} + runtimeTokenManager.Invalidate() } func newRuntimeContentScanner() safety.Scanner { diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go index 550ec4a5..caae1c0e 100644 --- a/internal/app/runner_full_coverage_test.go +++ b/internal/app/runner_full_coverage_test.go @@ -50,9 +50,9 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) { inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"} prefetched := make(chan struct{}, 1) - runnerGetCachedRuntimeToken = func(context.Context) string { + runnerGetCachedRuntimeToken = func(context.Context) (string, error) { prefetched <- struct{}{} - return "" + return "", nil } r := &runtimeRunner{ loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr), @@ -197,7 +197,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { return nil } - authErr := apperrors.NewAuth("expired") + authErr := apperrors.NewAuth("expired", apperrors.WithReason("http_401")) runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { return transport.ToolCallResult{}, authErr } @@ -290,10 +290,12 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) { oldStdioInit := runnerStdioEnsureInitialized + oldStdioList := runnerStdioListTools oldStdioCall := runnerStdioCallTool oldEdition := edition.Get() t.Cleanup(func() { runnerStdioEnsureInitialized = oldStdioInit + runnerStdioListTools = oldStdioList runnerStdioCallTool = oldStdioCall edition.Override(oldEdition) StopAllStdioClients() @@ -309,6 +311,14 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test t.Fatalf("stdio initialize error = %v", err) } runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil } + runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) { + return transport.ToolsListResult{ + Tools: []transport.ToolDescriptor{{ + Name: "tool", + InputSchema: map[string]any{"type": "object"}, + }}, + }, nil + } runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) { return transport.ToolCallResult{}, wantErr } @@ -327,21 +337,31 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented { t.Fatalf("stdio success = %#v, %v", got, err) } + RegisterStdioClient("plugin/server-key", client) + overlayIDInvocation := inv + overlayIDInvocation.CanonicalProduct = "overlay-id" + if got, err := r.executeInvocation( + context.Background(), + "stdio://plugin/server-key", + overlayIDInvocation, + ); err != nil || !got.Invocation.Implemented { + t.Fatalf("stdio endpoint-key lookup = %#v, %v", got, err) + } r.globalFlags.Token = " explicit " - if got := r.resolveAuthToken(context.Background()); got != "explicit" { - t.Fatalf("explicit auth token = %q", got) + if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" { + t.Fatalf("explicit auth token = %q, %v", got, err) } edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) { _, _ = fallback() return "provided", nil }}) r.globalFlags.Token = "" - if got := r.resolveAuthToken(context.Background()); got != "provided" { - t.Fatalf("provided auth token = %q", got) + if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "provided" { + t.Fatalf("provided auth token = %q, %v", got, err) } - if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" { - t.Fatalf("runtime explicit token = %q", got) + if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" { + t.Fatalf("runtime explicit token = %q, %v", got, err) } t.Setenv(envDWSChannel, "channel") diff --git a/internal/app/schema_completeness_test.go b/internal/app/schema_completeness_test.go index e9a40b56..9e4dca85 100644 --- a/internal/app/schema_completeness_test.go +++ b/internal/app/schema_completeness_test.go @@ -14,7 +14,7 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) { if err != nil { t.Fatal(err) } - root := NewRootCommand() + root := NewSchemaSourceRootCommand() if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil { t.Fatal(err) } diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index c4d63a21..81a39d50 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -17,6 +17,7 @@ import ( "archive/zip" "context" "encoding/json" + "errors" "fmt" "io" "mime" @@ -36,25 +37,25 @@ import ( ) var ( - skillLoadAccessToken = loadSkillAccessToken - skillDownloadToTmp = downloadSkillToTmpDir - skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) } - skillNewRequest = http.NewRequestWithContext - skillLoadTokenData = authpkg.LoadTokenData - skillResolveTargetPath = resolveSkillTargetPath - skillFetchDownloadInfo = fetchSkillDownloadInfo - skillDownloadFile = downloadSkillFile - skillExtractZip = extractSkillZip - skillUserHomeDir = os.UserHomeDir - skillMkdirTemp = os.MkdirTemp - skillCreate = os.Create - skillCreateTemp = os.CreateTemp - skillRemoveAll = os.RemoveAll - skillRemove = os.Remove - skillMkdirAll = os.MkdirAll - skillOpenFile = os.OpenFile - skillCopy = io.Copy - skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() } + skillLoadAccessToken = loadSkillAccessToken + skillDownloadToTmp = downloadSkillToTmpDir + skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) } + skillNewRequest = http.NewRequestWithContext + skillResolveAccessToken = ResolveAuxiliaryAccessToken + skillResolveTargetPath = resolveSkillTargetPath + skillFetchDownloadInfo = fetchSkillDownloadInfo + skillDownloadFile = downloadSkillFile + skillExtractZip = extractSkillZip + skillUserHomeDir = os.UserHomeDir + skillMkdirTemp = os.MkdirTemp + skillCreate = os.Create + skillCreateTemp = os.CreateTemp + skillRemoveAll = os.RemoveAll + skillRemove = os.Remove + skillMkdirAll = os.MkdirAll + skillOpenFile = os.OpenFile + skillCopy = io.Copy + skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() } ) func init() { @@ -296,7 +297,7 @@ func newSkillAddHintCommand() *cobra.Command { func runSkillGet(cmd *cobra.Command, args []string) error { skillID, _ := cmd.Flags().GetString("skill-id") - accessToken, err := skillLoadAccessToken() + accessToken, err := skillLoadAccessToken(cmd.Context()) if err != nil { return err } @@ -319,7 +320,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error { if source == "" { source, _ = cmd.Flags().GetString("scopes") } - accessToken, err := skillLoadAccessToken() + accessToken, err := skillLoadAccessToken(cmd.Context()) if err != nil { return err } @@ -388,7 +389,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets())) } - accessToken, err := skillLoadAccessToken() + accessToken, err := skillLoadAccessToken(cmd.Context()) if err != nil { return err } @@ -441,13 +442,16 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { return nil } -func loadSkillAccessToken() (string, error) { +func loadSkillAccessToken(ctx context.Context) (string, error) { configDir := defaultConfigDir() - tokenData, err := skillLoadTokenData(configDir) - if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() { + token, err := skillResolveAccessToken(ctx, configDir, "") + if errors.Is(err, authpkg.ErrTokenDataNotFound) { return "", skillAuthError() } - return tokenData.AccessToken, nil + if err != nil { + return "", fmt.Errorf("resolve skill access token: %w", err) + } + return token, nil } func skillAuthError() error { diff --git a/internal/app/skill_command_full_coverage_test.go b/internal/app/skill_command_full_coverage_test.go index d2331c3d..e686867b 100644 --- a/internal/app/skill_command_full_coverage_test.go +++ b/internal/app/skill_command_full_coverage_test.go @@ -57,11 +57,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. }) fail := errors.New("failure") cmd := skillCoverageCommand() - skillLoadAccessToken = func() (string, error) { return "", fail } + skillLoadAccessToken = func(context.Context) (string, error) { return "", fail } if err := runSkillGet(cmd, nil); !errors.Is(err, fail) { t.Fatalf("skill get auth error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "token", nil } + skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil } skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail } if err := runSkillFind(cmd, nil); err == nil { t.Fatal("skill find request failure should propagate") @@ -72,11 +72,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. t.Fatalf("skill get download error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "", fail } + skillLoadAccessToken = func(context.Context) (string, error) { return "", fail } if err := runSkillFind(cmd, nil); !errors.Is(err, fail) { t.Fatalf("skill find auth error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "token", nil } + skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil } skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail } if err := runSkillFind(cmd, nil); err == nil { t.Fatal("skill find network failure should propagate") @@ -111,11 +111,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. t.Fatal("invalid skill target should fail") } skillResolveTargetPath = func(string) (string, error) { return "dest", nil } - skillLoadAccessToken = func() (string, error) { return "", fail } + skillLoadAccessToken = func(context.Context) (string, error) { return "", fail } if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { t.Fatalf("skill add auth error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "token", nil } + skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil } skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail } if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { t.Fatalf("skill info error = %v", err) @@ -152,25 +152,35 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) { oldHTTP := skillHTTPDo - oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData + oldNewRequest, oldResolveToken := skillNewRequest, skillResolveAccessToken oldHome := skillUserHomeDir oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile t.Cleanup(func() { skillHTTPDo = oldHTTP - skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken + skillNewRequest, skillResolveAccessToken = oldNewRequest, oldResolveToken skillUserHomeDir = oldHome skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen }) fail := errors.New("failure") - skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil } - if _, err := loadSkillAccessToken(); err == nil { + skillResolveAccessToken = func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + } + if _, err := loadSkillAccessToken(context.Background()); err == nil { t.Fatal("invalid skill access token succeeded") } - skillLoadTokenData = oldLoadToken + canceled, cancel := context.WithCancel(context.Background()) + cancel() + skillResolveAccessToken = func(ctx context.Context, _, _ string) (string, error) { + return "", ctx.Err() + } + if _, err := loadSkillAccessToken(canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("skill token cancellation = %v", err) + } + skillResolveAccessToken = oldResolveToken skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail } if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil { t.Fatal("download-info request failure should propagate") diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 7caa94b3..1fd5094e 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -18,7 +18,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "os" @@ -397,9 +396,11 @@ func TestSkillInstallRequiresAuth(t *testing.T) { configDir := filepath.Join(tempDir, "config") t.Setenv("DWS_CONFIG_DIR", configDir) t.Cleanup(CloseFileLogger) - originalLoadToken := skillLoadTokenData - skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") } - t.Cleanup(func() { skillLoadTokenData = originalLoadToken }) + originalResolveToken := skillResolveAccessToken + skillResolveAccessToken = func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + } + t.Cleanup(func() { skillResolveAccessToken = originalResolveToken }) // Ensure the config directory exists but has no token if err := os.MkdirAll(configDir, 0755); err != nil { @@ -679,16 +680,11 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) { configDir := filepath.Join(t.TempDir(), "config") t.Setenv("DWS_CONFIG_DIR", configDir) t.Cleanup(CloseFileLogger) - originalLoadToken := skillLoadTokenData - skillLoadTokenData = func(string) (*authpkg.TokenData, error) { - return &authpkg.TokenData{ - AccessToken: "test-token", - RefreshToken: "refresh-token", - ExpiresAt: time.Now().Add(time.Hour), - RefreshExpAt: time.Now().Add(24 * time.Hour), - }, nil + originalResolveToken := skillResolveAccessToken + skillResolveAccessToken = func(context.Context, string, string) (string, error) { + return "test-token", nil } - t.Cleanup(func() { skillLoadTokenData = originalLoadToken }) + t.Cleanup(func() { skillResolveAccessToken = originalResolveToken }) var gotSources []string var gotScopes []string diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index 77b212ac..88d5f8c5 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -52,6 +52,10 @@ func TestMain(m *testing.M) { _ = os.RemoveAll(tmpDir) panic("set " + keychain.StorageDirEnv + ": " + err.Error()) } + if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set " + keychain.TestNamespaceEnv + ": " + err.Error()) + } if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil { _ = os.RemoveAll(tmpDir) panic("set DWS_CONFIG_DIR: " + err.Error()) @@ -79,6 +83,12 @@ func TestMain(m *testing.M) { StopAllStdioClients() CloseAuditSink() CloseFileLogger() + if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { + fmt.Fprintf(os.Stderr, "internal/app keychain cleanup: %v\n", err) + if code == 0 { + code = 1 + } + } if err := os.RemoveAll(tmpDir); err != nil { fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err) if code == 0 { diff --git a/internal/auth/access_token_snapshot_test.go b/internal/auth/access_token_snapshot_test.go new file mode 100644 index 00000000..4db27780 --- /dev/null +++ b/internal/auth/access_token_snapshot_test.go @@ -0,0 +1,70 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *testing.T) { + oldLoad := oauthLoadToken + want := errors.New("keychain permission denied") + oauthLoadToken = func(string) (*TokenData, error) { return nil, want } + t.Cleanup(func() { oauthLoadToken = oldLoad }) + + _, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background()) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want cause %v", err, want) + } + if errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("load failure was misclassified as missing credentials: %v", err) + } +} + +func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) { + oldLoad := oauthLoadToken + want := errors.New("keychain permission denied") + oauthLoadToken = func(string) (*TokenData, error) { return nil, want } + t.Cleanup(func() { oauthLoadToken = oldLoad }) + + _, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want cause %v", err, want) + } +} + +func TestCrossPlatformCoverageOAuthProviderTokenSnapshotReturnsExpiryMetadata(t *testing.T) { + oldLoad := oauthLoadToken + expiresAt := time.Now().Add(time.Hour) + oauthLoadToken = func(string) (*TokenData, error) { + return &TokenData{AccessToken: "token", ExpiresAt: expiresAt}, nil + } + t.Cleanup(func() { oauthLoadToken = oldLoad }) + + snapshot, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + if snapshot.AccessToken != "token" || !snapshot.ExpiresAt.Equal(expiresAt) { + t.Fatalf("snapshot = %#v", snapshot) + } +} + +func TestCrossPlatformCoverageTokenMarkerRevisionChangesOnEveryPublication(t *testing.T) { + configDir := t.TempDir() + if err := WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + first, present, err := ReadTokenMarkerRevision(configDir) + if err != nil || !present || first == "" { + t.Fatalf("first marker = %q, %v, %v", first, present, err) + } + if err := WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + second, present, err := ReadTokenMarkerRevision(configDir) + if err != nil || !present || second == "" || second == first { + t.Fatalf("second marker = %q, %v, %v; first=%q", second, present, err, first) + } +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 644a9aba..30e86fc1 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -722,6 +722,6 @@ func isInvalidGrantError(err error) bool { if err == nil { return false } - msg := strings.ToLower(err.Error()) + msg := strings.ToLower(err.Error() + " " + httpStatusResponseBody(err)) return strings.Contains(msg, "invalid_grant") || (strings.Contains(msg, "code") && strings.Contains(msg, "expired")) } diff --git a/internal/auth/force_refresh.go b/internal/auth/force_refresh.go index 05504b43..c6b893fe 100644 --- a/internal/auth/force_refresh.go +++ b/internal/auth/force_refresh.go @@ -13,7 +13,50 @@ package auth -import "time" +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +const rejectedTokenRefreshFailureCooldown = 3 * time.Second + +type rejectedTokenRefreshKey struct { + configDir string + profile string + tokenDigest [sha256.Size]byte +} + +type rejectedTokenRefreshCall struct { + done chan struct{} + participants int + token string + err error +} + +type rejectedTokenRefreshFailure struct { + at time.Time + err error +} + +var rejectedTokenRefreshCoordinator = struct { + sync.Mutex + inFlight map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall + failures map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure + now func() time.Time +}{ + inFlight: make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall), + failures: make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure), + now: time.Now, +} // MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past // instant (preserving access_token and refresh_token), and writes it back. The @@ -39,3 +82,192 @@ func MarkAccessTokenStale(configDir string) error { data.ExpiresAt = time.Now().Add(-1 * time.Minute) return SaveTokenData(configDir, data) } + +// ForceRefreshRejectedToken refreshes rejectedAccessToken only while it is +// still the credential stored for the active profile. The compare and refresh +// run under the same process + file lock used by ordinary expiry refresh, so a +// late rejection cannot invalidate or refresh over a token another caller has +// already rotated. +// +// When the stored token no longer matches, the newer token is returned without +// calling the refresh endpoint. Refresh failures leave the stored credential in +// place; login/logout remain the only owners of credential deletion. +func (p *OAuthProvider) ForceRefreshRejectedToken(ctx context.Context, rejectedAccessToken string) (string, error) { + if p == nil || strings.TrimSpace(p.configDir) == "" { + return "", fmt.Errorf("config directory is empty") + } + rejectedAccessToken = strings.TrimSpace(rejectedAccessToken) + if rejectedAccessToken == "" { + return "", fmt.Errorf("rejected access token is empty") + } + if ctx == nil { + ctx = context.Background() + } + + profile := strings.TrimSpace(RuntimeProfile()) + key := newRejectedTokenRefreshKey(p.configDir, profile, rejectedAccessToken) + call, leader := beginRejectedTokenRefresh(key) + if !leader { + select { + case <-call.done: + return call.token, call.err + case <-ctx.Done(): + return "", ctx.Err() + } + } + + token, err, recordFailure := p.forceRefreshRejectedTokenOnce(ctx, profile, rejectedAccessToken, key) + finishRejectedTokenRefresh(key, call, token, err, recordFailure) + return token, err +} + +func (p *OAuthProvider) forceRefreshRejectedTokenOnce( + ctx context.Context, + profile string, + rejectedAccessToken string, + key rejectedTokenRefreshKey, +) (string, error, bool) { + lock, err := oauthAcquireLock(ctx, p.configDir) + if err != nil { + return "", fmt.Errorf("acquiring dual lock: %w", err), false + } + defer lock.Release() + + data, err := loadOAuthTokenUnderHeldLock(p.configDir, profile) + if err != nil { + return "", fmt.Errorf("reload rejected token: %w", err), false + } + current := strings.TrimSpace(data.AccessToken) + if current == "" { + clearRejectedTokenRefreshFailure(key) + return "", fmt.Errorf("stored access token is empty"), false + } + if current != rejectedAccessToken { + clearRejectedTokenRefreshFailure(key) + return current, nil, false + } + if cachedErr := recentRejectedTokenRefreshFailure(key); cachedErr != nil { + return "", cachedErr, false + } + if !data.IsRefreshTokenValid() { + return "", fmt.Errorf("refresh_token 已过期"), true + } + if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil { + return "", fmt.Errorf("本地登录态无法安全更新: %w", err), true + } + + refreshed, err := oauthRefreshToken(p, ctx, data) + if err != nil { + return "", err, true + } + if refreshed == nil || strings.TrimSpace(refreshed.AccessToken) == "" { + return "", fmt.Errorf("force refresh returned empty access token"), true + } + return strings.TrimSpace(refreshed.AccessToken), nil, false +} + +func newRejectedTokenRefreshKey(configDir, profile, rejectedAccessToken string) rejectedTokenRefreshKey { + canonicalDir := filepath.Clean(configDir) + if absolute, err := filepath.Abs(configDir); err == nil { + canonicalDir = filepath.Clean(absolute) + } + return rejectedTokenRefreshKey{ + configDir: canonicalDir, + profile: strings.TrimSpace(profile), + tokenDigest: sha256.Sum256([]byte(strings.TrimSpace(rejectedAccessToken))), + } +} + +func beginRejectedTokenRefresh(key rejectedTokenRefreshKey) (*rejectedTokenRefreshCall, bool) { + rejectedTokenRefreshCoordinator.Lock() + defer rejectedTokenRefreshCoordinator.Unlock() + + if call := rejectedTokenRefreshCoordinator.inFlight[key]; call != nil { + call.participants++ + return call, false + } + call := &rejectedTokenRefreshCall{done: make(chan struct{}), participants: 1} + rejectedTokenRefreshCoordinator.inFlight[key] = call + return call, true +} + +func finishRejectedTokenRefresh( + key rejectedTokenRefreshKey, + call *rejectedTokenRefreshCall, + token string, + err error, + recordFailure bool, +) { + rejectedTokenRefreshCoordinator.Lock() + defer rejectedTokenRefreshCoordinator.Unlock() + + call.token = token + call.err = err + delete(rejectedTokenRefreshCoordinator.inFlight, key) + if err == nil { + delete(rejectedTokenRefreshCoordinator.failures, key) + } else if recordFailure && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + now := time.Now() + if rejectedTokenRefreshCoordinator.now != nil { + now = rejectedTokenRefreshCoordinator.now() + } + rejectedTokenRefreshCoordinator.failures[key] = rejectedTokenRefreshFailure{at: now, err: err} + } + close(call.done) +} + +func recentRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) error { + rejectedTokenRefreshCoordinator.Lock() + defer rejectedTokenRefreshCoordinator.Unlock() + + now := time.Now() + if rejectedTokenRefreshCoordinator.now != nil { + now = rejectedTokenRefreshCoordinator.now() + } + for failureKey, failure := range rejectedTokenRefreshCoordinator.failures { + age := now.Sub(failure.at) + if age < 0 || age >= rejectedTokenRefreshFailureCooldown { + delete(rejectedTokenRefreshCoordinator.failures, failureKey) + } + } + if failure, ok := rejectedTokenRefreshCoordinator.failures[key]; ok { + return failure.err + } + return nil +} + +func clearRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) { + rejectedTokenRefreshCoordinator.Lock() + delete(rejectedTokenRefreshCoordinator.failures, key) + rejectedTokenRefreshCoordinator.Unlock() +} + +// loadOAuthTokenUnderHeldLock mirrors LoadTokenDataForProfile without taking a +// second, non-reentrant auth lock. Opaque edition storage hooks (for example +// Wukong's encrypted .data file) are read inside the caller's dual lock so the +// compare-and-refresh decision covers both Core and embedded storage. +func loadOAuthTokenUnderHeldLock(configDir, profile string) (*TokenData, error) { + hooks := edition.Get() + if hooks.LoadToken == nil { + data, err := oauthLoadTokenLocked(configDir, profile) + if err != nil { + return nil, err + } + if data == nil { + return nil, fmt.Errorf("stored token data is empty") + } + return data, nil + } + if strings.TrimSpace(profile) != "" { + return nil, fmt.Errorf("profile selection is not supported by the current auth backend") + } + blob, err := hooks.LoadToken(configDir) + if err != nil { + return nil, err + } + var data TokenData + if err := json.Unmarshal(blob, &data); err != nil { + return nil, fmt.Errorf("parsing token data from hook: %w", err) + } + return &data, nil +} diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 41c6298b..4249bbcf 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -47,8 +47,10 @@ func (m *Manager) GetToken() (string, string, error) { } return token, "file", nil } - - return "", "", fmt.Errorf("%s", i18n.T("未找到认证信息,请运行 dws auth login")) + if err != nil && !os.IsNotExist(err) { + return "", "", fmt.Errorf("load legacy token: %w", err) + } + return "", "", fmt.Errorf("%s: %w", i18n.T("未找到认证信息,请运行 dws auth login"), ErrTokenDataNotFound) } func (m *Manager) GetMCPURL() (string, error) { diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index bac901c9..61e91ceb 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -237,12 +237,21 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) } defer resp.Body.Close() - data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) - if err != nil { - return nil, fmt.Errorf("reading response: %w", err) - } + data, readErr := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(data, 200)) + // Preserve structured HTTP status semantics even when the response + // body is truncated. The body is diagnostic-only here, so read it + // best-effort and classify retryability from the status code. + if readErr != nil { + data = nil + } + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + responseBody: truncateBody(data, 200), + } + } + if readErr != nil { + return nil, fmt.Errorf("reading response: %w", readErr) } return data, nil } diff --git a/internal/auth/oauth_postjson_test.go b/internal/auth/oauth_postjson_test.go new file mode 100644 index 00000000..78446fc2 --- /dev/null +++ b/internal/auth/oauth_postjson_test.go @@ -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 auth + +import ( + "context" + "errors" + "io" + "net/http" + "testing" +) + +type postJSONRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f postJSONRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type oauthBrokenBody struct{} + +func (oauthBrokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (oauthBrokenBody) Close() error { return nil } + +func TestCrossPlatformCoveragePostJSONTruncatedErrorBodyKeepsHTTPStatus(t *testing.T) { + for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil + })}} + + _, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"}) + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != status { + t.Fatalf("postJSON() error = %v, want HTTPStatusError %d", err, status) + } + if errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("HTTP status error should not expose diagnostic body read failure: %v", err) + } + if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient { + t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got) + } + }) + } +} + +func TestCrossPlatformCoveragePostJSONOKTruncatedBodyIsTransient(t *testing.T) { + provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil + })}} + + _, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"}) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("postJSON() error = %v, want io.ErrUnexpectedEOF", err) + } + if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient { + t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got) + } +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index a9d070d2..1e5bebe0 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "log/slog" "net" @@ -115,6 +116,12 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro // Smart degradation: try silent refresh before opening browser. if !force { data, err := oauthLoadToken(p.configDir) + if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) { + if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr) + } + return nil, fmt.Errorf("load existing access token: %w", err) + } if err == nil { // Case 1: access_token still valid — no action needed. if data.IsAccessTokenValid() { @@ -295,7 +302,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro callbackTokenMu.Unlock() w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = fmt.Fprintf(w, "

授权失败

%s

", exchangeErr.Error()) + _, _ = fmt.Fprintf(w, "

授权失败

%s

", html.EscapeString(oauthExchangeDisplayError(exchangeErr))) select { case resultCh <- callbackResult{err: exchangeErr}: default: @@ -636,36 +643,63 @@ continueLogin: return tokenData, nil } -// GetAccessToken returns a valid access token, auto-refreshing if needed. -// Uses a file lock with double-check pattern to prevent concurrent refresh -// from multiple CLI processes. -func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) { +func oauthExchangeDisplayError(err error) string { + var statusErr *HTTPStatusError + if errors.As(err, &statusErr) && statusErr != nil { + return fmt.Sprintf("HTTP %d: token exchange failed", statusErr.StatusCode) + } + return err.Error() +} + +// GetTokenSnapshot returns a valid token together with its expiry metadata. +// Storage and refresh failures retain their original cause; only a confirmed +// missing credential is reported as ErrTokenDataNotFound. +func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error) { data, err := oauthLoadToken(p.configDir) if err != nil { - return "", errors.New(i18n.T("未登录,请运行 dws auth login")) + if errors.Is(err, ErrTokenDataNotFound) || os.IsNotExist(err) { + return nil, fmt.Errorf("%s: %w", i18n.T("未登录,请运行 dws auth login"), ErrTokenDataNotFound) + } + return nil, fmt.Errorf("load access token: %w", err) } // Fast path: access_token still valid — no lock needed. if data.IsAccessTokenValid() { - return data.AccessToken, nil + return data, nil } // Slow path: token expired — try locked refresh. if data.IsRefreshTokenValid() { refreshed, rErr := p.lockedRefresh(ctx) if rErr == nil { - return refreshed.AccessToken, nil + return refreshed, nil + } + // A network, timeout, rate-limit or 5xx failure does not invalidate the + // refresh credential. Keep the profile active so a long-running source + // can retry after backoff. Terminal and unknown failures remain fatal. + if ClassifyRefreshFailure(rErr) != RefreshFailureTransient { + _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) } - _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) if p.logger != nil { p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr) } - return "", fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr) + return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr) } else { _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) } - return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录")) + return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound) +} + +// GetAccessToken returns a valid access token, auto-refreshing if needed. +// Uses a file lock with double-check pattern to prevent concurrent refresh +// from multiple CLI processes. +func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) { + data, err := p.GetTokenSnapshot(ctx) + if err != nil { + return "", err + } + return strings.TrimSpace(data.AccessToken), nil } // lockedRefresh attempts to refresh the token while holding dual-layer locks. @@ -697,7 +731,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { // Double-check: re-load from disk — another goroutine/process may have refreshed // while we were waiting for the lock. - data, err := oauthLoadTokenLocked(p.configDir, RuntimeProfile()) + data, err := loadOAuthTokenUnderHeldLock(p.configDir, RuntimeProfile()) if err != nil { return nil, err } diff --git a/internal/auth/oauth_provider_coverage_test.go b/internal/auth/oauth_provider_coverage_test.go index 1be87470..6c9269b8 100644 --- a/internal/auth/oauth_provider_coverage_test.go +++ b/internal/auth/oauth_provider_coverage_test.go @@ -18,6 +18,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) type oauthLoginFixture struct { @@ -33,8 +35,79 @@ type oauthLoginFixture struct { exchangeError atomic.Bool } +type oauthLoginResult struct { + token *TokenData + err error +} + +type oauthHTTPResult struct { + status int + body string + err error +} + +const oauthTestWaitTimeout = 5 * time.Second + +func isolateOAuthPersistence(t *testing.T) { + t.Helper() + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) +} + +func startOAuthLogin(t *testing.T, parent context.Context, f *oauthLoginFixture) <-chan oauthLoginResult { + t.Helper() + ctx, cancel := context.WithCancel(parent) + done := make(chan oauthLoginResult, 1) + finished := make(chan struct{}) + go func() { + defer close(finished) + token, err := f.provider.Login(ctx, true) + done <- oauthLoginResult{token: token, err: err} + }() + t.Cleanup(func() { + cancel() + select { + case <-finished: + case <-time.After(oauthTestWaitTimeout): + t.Errorf("OAuth Login goroutine did not stop after cancellation") + } + }) + return done +} + +func awaitOAuthLogin(t *testing.T, done <-chan oauthLoginResult) oauthLoginResult { + t.Helper() + select { + case result := <-done: + return result + case <-time.After(oauthTestWaitTimeout): + t.Fatal("timed out waiting for OAuth Login") + return oauthLoginResult{} + } +} + +func waitOAuthSignal(t *testing.T, signal <-chan struct{}, done <-chan oauthLoginResult, name string) { + t.Helper() + select { + case <-signal: + case result := <-done: + t.Fatalf("OAuth Login returned before %s: token=%#v err=%v", name, result.token, result.err) + case <-time.After(oauthTestWaitTimeout): + t.Fatalf("timed out waiting for OAuth %s", name) + } +} + +func closeOAuthRelease(ch chan struct{}) { + select { + case <-ch: + default: + close(ch) + } +} + func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture { t.Helper() + isolateOAuthPersistence(t) SetClientID("") SetClientSecret("") resetClientIDFromMCP() @@ -91,6 +164,10 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth } })) t.Cleanup(f.server.Close) + t.Cleanup(func() { + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) + }) f.configDir = setupMCPConfigDir(t, f.server.URL) oldClient := oauthHTTPClient @@ -118,17 +195,25 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth func httpGetBody(t *testing.T, rawURL string) (int, string) { t.Helper() + result := getHTTPBody(rawURL) + if result.err != nil { + t.Fatalf("GET %s: %v", rawURL, result.err) + } + return result.status, result.body +} + +func getHTTPBody(rawURL string) oauthHTTPResult { client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Get(rawURL) if err != nil { - t.Fatalf("GET %s: %v", rawURL, err) + return oauthHTTPResult{err: err} } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { - t.Fatal(err) + return oauthHTTPResult{err: err} } - return resp.StatusCode, string(data) + return oauthHTTPResult{status: resp.StatusCode, body: string(data)} } func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) { @@ -136,17 +221,7 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) { return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}} }) - loginDone := make(chan struct { - token *TokenData - err error - }, 1) - go func() { - token, err := f.provider.Login(context.Background(), true) - loginDone <- struct { - token *TokenData - err error - }{token, err} - }() + loginDone := startOAuthLogin(t, context.Background(), f) for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} { _, body := httpGetBody(t, f.callbackBase+path) @@ -166,18 +241,17 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) { t.Fatalf("success page = %q", body) } - callbackDone := make(chan string, 1) + callbackDone := make(chan oauthHTTPResult, 1) go func() { - _, callbackBody := httpGetBody(t, f.callbackBase+CallbackPath+"?code=good") - callbackDone <- callbackBody + callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=good") }() - <-f.exchangeEntered + waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange") _, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good") if !strings.Contains(body, "正在处理授权") { t.Fatalf("concurrent callback = %q", body) } - close(f.exchangeRelease) - <-f.statusEntered + closeOAuthRelease(f.exchangeRelease) + waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check") _, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good") if !strings.Contains(body, " 1}} }) - close(f.exchangeRelease) - close(f.statusRelease) - done := make(chan error, 1) - go func() { - _, err := f.provider.Login(context.Background(), true) - done <- err - }() + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) + done := startOAuthLogin(t, context.Background(), f) _, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending") - if err := <-done; err != nil { - t.Fatalf("poll-enabled login failed: %v", err) + if result := awaitOAuthLogin(t, done); result.err != nil { + t.Fatalf("poll-enabled login failed: %v", result.err) } }) @@ -498,17 +560,13 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) { f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}} }) - close(f.exchangeRelease) - close(f.statusRelease) + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - _, err := f.provider.Login(ctx, true) - done <- err - }() + done := startOAuthLogin(t, ctx, f) _, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending") cancel() - if err := <-done; err == nil { + if result := awaitOAuthLogin(t, done); result.err == nil { t.Fatal("canceled pending login succeeded") } }) @@ -519,23 +577,20 @@ func TestCrossPlatformCoverageOAuthLoginExchangeFailure(t *testing.T) { return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}} }) f.exchangeError.Store(true) - close(f.exchangeRelease) - close(f.statusRelease) - done := make(chan error, 1) - go func() { - _, err := f.provider.Login(context.Background(), true) - done <- err - }() + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) + done := startOAuthLogin(t, context.Background(), f) _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad") if !strings.Contains(body, "failed") { t.Fatalf("exchange failure page = %q", body) } - if err := <-done; err == nil { + if result := awaitOAuthLogin(t, done); result.err == nil { t.Fatal("exchange failure login succeeded") } } func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) { + isolateOAuthPersistence(t) t.Setenv("DWS_CLIENT_ID", "") t.Setenv("DWS_CLIENT_SECRET", "") dir := t.TempDir() @@ -640,6 +695,7 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) { } func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) { + isolateOAuthPersistence(t) oldLoad := oauthLoadToken oldLoadLocked := oauthLoadTokenLocked oldAcquire := oauthAcquireLock @@ -800,29 +856,24 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) { oauthSuccessPause = 0 oauthSleep = func(time.Duration) {} - type loginResult struct { - token *TokenData - err error - } - startLogin := func(ctx context.Context, f *oauthLoginFixture) <-chan loginResult { + finishExchange := func(t *testing.T, f *oauthLoginFixture, done <-chan oauthLoginResult, code string) string { t.Helper() - done := make(chan loginResult, 1) + bodyCh := make(chan oauthHTTPResult, 1) go func() { - token, err := f.provider.Login(ctx, true) - done <- loginResult{token: token, err: err} + bodyCh <- getHTTPBody(f.callbackBase + CallbackPath + "?code=" + url.QueryEscape(code)) }() - return done - } - finishExchange := func(t *testing.T, f *oauthLoginFixture, code string) string { - t.Helper() - bodyCh := make(chan string, 1) - go func() { - _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code="+url.QueryEscape(code)) - bodyCh <- body - }() - <-f.exchangeEntered - close(f.exchangeRelease) - return <-bodyCh + waitOAuthSignal(t, f.exchangeEntered, done, "token exchange") + closeOAuthRelease(f.exchangeRelease) + select { + case result := <-bodyCh: + if result.err != nil { + t.Fatalf("OAuth callback failed: %v", result.err) + } + return result.body + case <-time.After(oauthTestWaitTimeout): + t.Fatal("timed out waiting for OAuth callback") + return "" + } } t.Run("switch organization and cached disabled pages", func(t *testing.T) { @@ -835,8 +886,8 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) { return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil } f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} }) - done := startLogin(context.Background(), f) - if body := finishExchange(t, f, "first"); !strings.Contains(body, "= http.StatusInternalServerError { + return RefreshFailureTransient + } + if statusErr.StatusCode == http.StatusBadRequest || + statusErr.StatusCode == http.StatusUnauthorized || + statusErr.StatusCode == http.StatusForbidden { + return RefreshFailureTerminal + } + return RefreshFailureUnknown +} diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go new file mode 100644 index 00000000..e27a4920 --- /dev/null +++ b/internal/auth/refresh_failure_test.go @@ -0,0 +1,155 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package auth + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestCrossPlatformCoverageClassifyRefreshFailureUsesStructuredSignals(t *testing.T) { + tests := []struct { + name string + err error + want RefreshFailureClass + }{ + {name: "deadline", err: context.DeadlineExceeded, want: RefreshFailureTransient}, + {name: "network", err: &url.Error{Op: "Post", URL: "https://oauth.test", Err: context.DeadlineExceeded}, want: RefreshFailureTransient}, + {name: "request timeout", err: &HTTPStatusError{StatusCode: http.StatusRequestTimeout}, want: RefreshFailureTransient}, + {name: "rate limited", err: &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, want: RefreshFailureTransient}, + {name: "server unavailable", err: &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}, want: RefreshFailureTransient}, + {name: "refresh rejected", err: &HTTPStatusError{StatusCode: http.StatusUnauthorized}, want: RefreshFailureTerminal}, + {name: "invalid grant", err: &HTTPStatusError{StatusCode: http.StatusBadRequest}, want: RefreshFailureTerminal}, + {name: "forbidden", err: &HTTPStatusError{StatusCode: http.StatusForbidden}, want: RefreshFailureTerminal}, + {name: "local persistence", err: errors.New("save refreshed token failed"), want: RefreshFailureUnknown}, + {name: "nil error", err: nil, want: RefreshFailureUnknown}, + {name: "dns failure", err: &net.DNSError{Err: "no such host", Name: "oauth.test"}, want: RefreshFailureTransient}, + {name: "redirect status", err: &HTTPStatusError{StatusCode: http.StatusFound}, want: RefreshFailureUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ClassifyRefreshFailure(tt.err); got != tt.want { + t.Fatalf("ClassifyRefreshFailure() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCrossPlatformCoverageHTTPStatusErrorRetainsStatusThroughWrapping(t *testing.T) { + want := &HTTPStatusError{StatusCode: http.StatusTooManyRequests} + err := errors.Join(errors.New("refresh failed"), want) + if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient { + t.Fatalf("ClassifyRefreshFailure() = %q, want transient", got) + } + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("HTTP status error not retained: %v", err) + } + if got, want := statusErr.Error(), "HTTP 429"; got != want { + t.Fatalf("HTTP status error = %q, want %q", got, want) + } + var nilStatus *HTTPStatusError + if got, want := nilStatus.Error(), "OAuth endpoint request failed"; got != want { + t.Fatalf("nil HTTP status error = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoverageOAuthExchangeDisplayErrorFallsBackToPlainError(t *testing.T) { + if got, want := oauthExchangeDisplayError(&HTTPStatusError{StatusCode: http.StatusBadGateway}), "HTTP 502: token exchange failed"; got != want { + t.Fatalf("status display error = %q, want %q", got, want) + } + if got, want := oauthExchangeDisplayError(errors.New("exchange failed")), "exchange failed"; got != want { + t.Fatalf("plain display error = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoveragePostJSONClassifiesStatusWithoutLoggingResponseBody(t *testing.T) { + const secretBody = `{"refreshToken":"must-not-reach-logs"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(secretBody)) + })) + defer server.Close() + + provider := &OAuthProvider{httpClient: server.Client()} + _, err := provider.postJSON(context.Background(), server.URL, map[string]string{"grantType": "refresh_token"}) + if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient { + t.Fatalf("ClassifyRefreshFailure() = %q, want transient: %v", got, err) + } + if strings.Contains(err.Error(), "must-not-reach-logs") { + t.Fatalf("postJSON error leaked response body: %v", err) + } + if got := httpStatusResponseBody(err); !strings.Contains(got, "must-not-reach-logs") { + t.Fatalf("postJSON did not retain bounded response details for internal classification: %q", got) + } +} + +func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientRefreshFailures(t *testing.T) { + oldLoad := oauthLoadToken + oldLoadLocked := oauthLoadTokenLocked + oldAcquire := oauthAcquireLock + oldRefresh := oauthRefreshToken + oldMark := oauthMarkProfile + oldEdition := edition.Get() + t.Cleanup(func() { + oauthLoadToken = oldLoad + oauthLoadTokenLocked = oldLoadLocked + oauthAcquireLock = oldAcquire + oauthRefreshToken = oldRefresh + oauthMarkProfile = oldMark + edition.Override(oldEdition) + }) + edition.Override(&edition.Hooks{}) + + expired := &TokenData{ + AccessToken: "expired-access", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshToken: "refresh", + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp", + UserID: "user", + } + oauthLoadToken = func(string) (*TokenData, error) { return expired, nil } + oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return expired, nil } + oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil } + + markCalls := 0 + oauthMarkProfile = func(_, _, status string) error { + if status != ProfileStatusExpired { + t.Fatalf("profile status = %q, want %q", status, ProfileStatusExpired) + } + markCalls++ + return nil + } + provider := NewOAuthProvider(t.TempDir(), nil) + + oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &HTTPStatusError{StatusCode: http.StatusServiceUnavailable} + } + if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTransient { + t.Fatalf("transient refresh error = %v", err) + } + if markCalls != 0 { + t.Fatalf("transient refresh marked profile expired %d times", markCalls) + } + + oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &HTTPStatusError{StatusCode: http.StatusUnauthorized} + } + if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTerminal { + t.Fatalf("terminal refresh error = %v", err) + } + if markCalls != 1 { + t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls) + } +} diff --git a/internal/auth/rejected_token_refresh_test.go b/internal/auth/rejected_token_refresh_test.go new file mode 100644 index 00000000..42e7bda6 --- /dev/null +++ b/internal/auth/rejected_token_refresh_test.go @@ -0,0 +1,510 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type rejectedTokenHookStore struct { + mu sync.Mutex + data TokenData + deletes int +} + +func (s *rejectedTokenHookStore) load(string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + return json.Marshal(s.data) +} + +func (s *rejectedTokenHookStore) save(_ string, blob []byte) error { + var data TokenData + if err := json.Unmarshal(blob, &data); err != nil { + return err + } + s.mu.Lock() + s.data = data + s.mu.Unlock() + return nil +} + +func (s *rejectedTokenHookStore) delete(string) error { + s.mu.Lock() + s.data = TokenData{} + s.deletes++ + s.mu.Unlock() + return nil +} + +func (s *rejectedTokenHookStore) snapshot() (TokenData, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data, s.deletes +} + +func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenHookStore { + t.Helper() + store := &rejectedTokenHookStore{data: data} + previousHooks := edition.Get() + edition.Override(&edition.Hooks{ + LoadToken: store.load, + SaveToken: store.save, + DeleteToken: store.delete, + }) + t.Cleanup(func() { edition.Override(previousHooks) }) + return store +} + +func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) { + t.Helper() + resetRejectedTokenRefreshCoordinator(t) + previous := oauthRefreshToken + oauthRefreshToken = fn + t.Cleanup(func() { oauthRefreshToken = previous }) +} + +func resetRejectedTokenRefreshCoordinator(t *testing.T) { + t.Helper() + reset := func() { + rejectedTokenRefreshCoordinator.Lock() + rejectedTokenRefreshCoordinator.inFlight = make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall) + rejectedTokenRefreshCoordinator.failures = make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure) + rejectedTokenRefreshCoordinator.now = time.Now + rejectedTokenRefreshCoordinator.Unlock() + } + reset() + t.Cleanup(reset) +} + +func waitForRejectedTokenRefreshParticipants(t *testing.T, key rejectedTokenRefreshKey, want int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + rejectedTokenRefreshCoordinator.Lock() + call := rejectedTokenRefreshCoordinator.inFlight[key] + got := 0 + if call != nil { + got = call.participants + } + rejectedTokenRefreshCoordinator.Unlock() + if got >= want { + return + } + if time.Now().After(deadline) { + t.Fatalf("refresh participants = %d, want %d", got, want) + } + time.Sleep(time.Millisecond) + } +} + +func installProfilesAcquireProbe(t *testing.T) <-chan struct{} { + t.Helper() + previous := profilesAcquireDualLock + attempted := make(chan struct{}, 1) + profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) { + attempted <- struct{}{} + return previous(ctx, configDir) + } + t.Cleanup(func() { profilesAcquireDualLock = previous }) + return attempted +} + +func waitForProfilesAcquire(t *testing.T, attempted <-chan struct{}) { + t.Helper() + select { + case <-attempted: + case <-time.After(2 * time.Second): + t.Fatal("public opaque token mutation did not enter the Core dual lock") + } +} + +func validRejectedTokenData(accessToken string) TokenData { + return TokenData{ + AccessToken: accessToken, + RefreshToken: "refresh-token", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + Source: "mcp", + ClientID: "client-id", + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenConcurrentCallersExchangeOnce(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + var refreshCalls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + if refreshCalls.Add(1) == 1 { + close(started) + } + <-release + updated := *data + updated.AccessToken = "new-access" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + provider := NewOAuthProvider(t.TempDir(), nil) + const workers = 8 + results := make(chan string, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + wg.Add(workers) + for range workers { + go func() { + defer wg.Done() + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + results <- token + errs <- err + }() + } + <-started + close(release) + wg.Wait() + close(results) + close(errs) + + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + for token := range results { + if token != "new-access" { + t.Fatalf("token = %q, want new-access", token) + } + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want 1", got) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "new-access" || deletes != 0 { + t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes) + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenFailurePreservesCredential(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + refreshErr := errors.New("temporary refresh failure") + installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, refreshErr + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).ForceRefreshRejectedToken(context.Background(), "old-access") + if !errors.Is(err, refreshErr) { + t.Fatalf("error = %v, want refresh cause", err) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 { + t.Fatalf("credential changed after transient failure: %#v, deletes=%d", stored, deletes) + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenFailureIsSingleflightAndCooledDown(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + previousProfile := RuntimeProfile() + SetRuntimeProfile("") + t.Cleanup(func() { SetRuntimeProfile(previousProfile) }) + + refreshErr := errors.New("temporary refresh failure") + var refreshCalls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + baseNow := time.Now() + var nowNanos atomic.Int64 + nowNanos.Store(baseNow.UnixNano()) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + call := refreshCalls.Add(1) + if call == 1 { + close(started) + <-release + return nil, refreshErr + } + updated := *data + updated.AccessToken = "recovered-access" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + rejectedTokenRefreshCoordinator.Lock() + rejectedTokenRefreshCoordinator.now = func() time.Time { + return time.Unix(0, nowNanos.Load()) + } + rejectedTokenRefreshCoordinator.Unlock() + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + const workers = 8 + start := make(chan struct{}) + ready := make(chan struct{}, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + wg.Add(workers) + for range workers { + go func() { + defer wg.Done() + ready <- struct{}{} + <-start + _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + errs <- err + }() + } + for range workers { + <-ready + } + close(start) + <-started + key := newRejectedTokenRefreshKey(configDir, "", "old-access") + waitForRejectedTokenRefreshParticipants(t, key, workers) + close(release) + wg.Wait() + close(errs) + + for err := range errs { + if !errors.Is(err, refreshErr) { + t.Fatalf("shared refresh error = %v, want %v", err, refreshErr) + } + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls after concurrent failure = %d, want 1", got) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 { + t.Fatalf("credential changed after shared failure: %#v, deletes=%d", stored, deletes) + } + + if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) { + t.Fatalf("cooldown error = %v, want %v", err, refreshErr) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls inside cooldown = %d, want 1", got) + } + + nowNanos.Store(baseNow.Add(rejectedTokenRefreshFailureCooldown + time.Nanosecond).UnixNano()) + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + if err != nil || token != "recovered-access" { + t.Fatalf("refresh after cooldown = %q, %v", token, err) + } + if got := refreshCalls.Load(); got != 2 { + t.Fatalf("refresh calls after cooldown = %d, want 2", got) + } + stored, deletes = store.snapshot() + if stored.AccessToken != "recovered-access" || deletes != 0 { + t.Fatalf("stored token after cooldown recovery = %q, deletes=%d", stored.AccessToken, deletes) + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenChangedDuringCooldownUsesNewToken(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + previousProfile := RuntimeProfile() + SetRuntimeProfile("") + t.Cleanup(func() { SetRuntimeProfile(previousProfile) }) + + refreshErr := errors.New("temporary refresh failure") + var refreshCalls atomic.Int32 + baseNow := time.Now() + installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + refreshCalls.Add(1) + return nil, refreshErr + }) + rejectedTokenRefreshCoordinator.Lock() + rejectedTokenRefreshCoordinator.now = func() time.Time { return baseNow } + rejectedTokenRefreshCoordinator.Unlock() + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) { + t.Fatalf("initial refresh error = %v, want %v", err, refreshErr) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("initial refresh calls = %d, want 1", got) + } + + store.mu.Lock() + store.data = validRejectedTokenData("externally-refreshed") + store.mu.Unlock() + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + if err != nil || token != "externally-refreshed" { + t.Fatalf("refresh after external publication = %q, %v", token, err) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("external publication triggered another exchange: calls=%d", got) + } + key := newRejectedTokenRefreshKey(configDir, "", "old-access") + rejectedTokenRefreshCoordinator.Lock() + _, failurePresent := rejectedTokenRefreshCoordinator.failures[key] + rejectedTokenRefreshCoordinator.Unlock() + if failurePresent { + t.Fatal("old-token failure cache was not cleared after external publication") + } +} + +func TestCrossPlatformCoverageOpaquePublisherWaitsForRejectedTokenRefresh(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + close(started) + <-release + updated := *data + updated.AccessToken = "refreshed-from-old" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + refreshResult := make(chan struct { + token string + err error + }, 1) + go func() { + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + refreshResult <- struct { + token string + err error + }{token: token, err: err} + }() + <-started + + acquireAttempted := installProfilesAcquireProbe(t) + publishResult := make(chan error, 1) + go func() { + publishResult <- SaveTokenData(configDir, ptrTokenData(validRejectedTokenData("login-published"))) + }() + waitForProfilesAcquire(t, acquireAttempted) + releaseOnce.Do(func() { close(release) }) + + refresh := <-refreshResult + if refresh.err != nil || refresh.token != "refreshed-from-old" { + t.Fatalf("refresh result = %q, %v", refresh.token, refresh.err) + } + if err := <-publishResult; err != nil { + t.Fatalf("publish token: %v", err) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "login-published" || deletes != 0 { + t.Fatalf("older refresh overwrote login publication: token=%q deletes=%d", stored.AccessToken, deletes) + } +} + +func TestCrossPlatformCoverageOpaqueLogoutWaitsForRejectedTokenRefresh(t *testing.T) { + for _, tc := range []struct { + name string + logout func(string) error + }{ + {name: "current profile", logout: func(configDir string) error { + return DeleteTokenDataForProfile(configDir, "") + }}, + {name: "all profiles", logout: DeleteAllTokenData}, + } { + t.Run(tc.name, func(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + close(started) + <-release + updated := *data + updated.AccessToken = "refreshed-before-logout" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + refreshResult := make(chan error, 1) + go func() { + _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + refreshResult <- err + }() + <-started + + acquireAttempted := installProfilesAcquireProbe(t) + logoutResult := make(chan error, 1) + go func() { logoutResult <- tc.logout(configDir) }() + waitForProfilesAcquire(t, acquireAttempted) + releaseOnce.Do(func() { close(release) }) + + if err := <-refreshResult; err != nil { + t.Fatalf("refresh: %v", err) + } + if err := <-logoutResult; err != nil { + t.Fatalf("logout: %v", err) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "" || stored.RefreshToken != "" || deletes != 1 { + t.Fatalf("refresh resurrected logged-out credential: %#v deletes=%d", stored, deletes) + } + }) + } +} + +func ptrTokenData(data TokenData) *TokenData { + return &data +} + +func TestCrossPlatformCoverageOAuthLockedRefreshReadsOpaqueEditionStore(t *testing.T) { + data := validRejectedTokenData("expired-access") + data.ExpiresAt = time.Now().Add(-time.Hour) + store := installRejectedTokenHookStore(t, data) + var refreshCalls atomic.Int32 + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, current *TokenData) (*TokenData, error) { + refreshCalls.Add(1) + updated := *current + updated.AccessToken = "proactively-refreshed" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + token, err := NewOAuthProvider(t.TempDir(), nil).GetAccessToken(context.Background()) + if err != nil || token != "proactively-refreshed" { + t.Fatalf("GetAccessToken() = %q, %v", token, err) + } + if refreshCalls.Load() != 1 { + t.Fatalf("refresh calls = %d, want 1", refreshCalls.Load()) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "proactively-refreshed" || deletes != 0 { + t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes) + } +} diff --git a/internal/auth/testmain_test.go b/internal/auth/testmain_test.go index 92310f85..3a84802d 100644 --- a/internal/auth/testmain_test.go +++ b/internal/auth/testmain_test.go @@ -14,6 +14,7 @@ package auth import ( + "fmt" "os" "testing" @@ -34,7 +35,17 @@ func TestMain(m *testing.M) { _ = os.RemoveAll(tmpDir) panic("set " + keychain.StorageDirEnv + ": " + err.Error()) } + if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set " + keychain.TestNamespaceEnv + ": " + err.Error()) + } code := m.Run() + if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { + fmt.Fprintf(os.Stderr, "internal/auth keychain cleanup: %v\n", err) + if code == 0 { + code = 1 + } + } _ = os.RemoveAll(tmpDir) os.Exit(code) } diff --git a/internal/auth/token.go b/internal/auth/token.go index 94a24fb9..518733b1 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -127,6 +127,10 @@ const tokenJSONFile = "token.json" type TokenMarker struct { UpdatedAt string `json:"updated_at"` ManualToken bool `json:"manual_token,omitempty"` + // Revision changes on every credential publication. Runtime token caches + // use it as a cheap cross-process invalidation signal without reading the + // platform keychain on every request. + Revision string `json:"revision,omitempty"` } // WriteTokenMarker writes a token.json marker containing only an updated_at @@ -147,6 +151,7 @@ func writeTokenMarker(configDir string, manual bool) error { marker := TokenMarker{ UpdatedAt: time.Now().Format(time.RFC3339), ManualToken: manual, + Revision: uuid.NewString(), } data, _ := tokenJSONMarshalIndent(marker, "", " ") if err := tokenMkdirAll(configDir, 0o700); err != nil { @@ -159,6 +164,27 @@ func writeTokenMarker(configDir string, manual bool) error { return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile)) } +// ReadTokenMarkerRevision returns the current credential publication revision. +// Existing markers without a revision remain readable, but callers must avoid +// caching them because they cannot prove that the credential is unchanged. +func ReadTokenMarkerRevision(configDir string) (revision string, present bool, err error) { + data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile)) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, fmt.Errorf("read token marker: %w", err) + } + var marker TokenMarker + if err := json.Unmarshal(data, &marker); err != nil { + // The marker is only a cache-coherency hint. A malformed historical or + // externally modified marker must disable caching, not make an otherwise + // valid credential unusable. + return "", true, nil + } + return strings.TrimSpace(marker.Revision), true, nil +} + func manualTokenMarkerActive(configDir string) (bool, error) { data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile)) if err != nil { @@ -184,13 +210,10 @@ func DeleteTokenMarker(configDir string) error { return nil } -// SaveTokenData persists TokenData. When an edition hook (SaveToken) is -// registered, it delegates entirely to the hook; otherwise it falls back -// to the default keychain-based storage. +// SaveTokenData persists TokenData under the auth dual lock. When an edition +// hook (SaveToken) is registered, the locked write delegates to that hook; +// otherwise it falls back to the default keychain-based storage. func SaveTokenData(configDir string, data *TokenData) error { - if h := edition.Get(); h.SaveToken != nil { - return saveTokenViaHook(h, configDir, data) - } return withProfilesLock(configDir, func() error { return saveTokenDataLocked(configDir, data) }) @@ -464,9 +487,8 @@ func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) { return orgData, nil } -// DeleteTokenData removes token data. When an edition hook (DeleteToken) is -// registered, it delegates entirely to the hook; otherwise it falls back -// to keychain + legacy cleanup. +// DeleteTokenData removes token data. Edition hooks and the default keychain +// path are both serialized with refresh through the auth dual lock. func DeleteTokenData(configDir string) error { return DeleteTokenDataForProfile(configDir, RuntimeProfile()) } @@ -478,7 +500,9 @@ func DeleteTokenDataForProfile(configDir, profile string) error { if strings.TrimSpace(profile) != "" { return fmt.Errorf("profile selection is not supported by the current auth backend") } - return h.DeleteToken(configDir) + return withProfilesLock(configDir, func() error { + return h.DeleteToken(configDir) + }) } return withProfilesLock(configDir, func() error { return deleteTokenDataForProfileLocked(configDir, profile) @@ -913,7 +937,9 @@ func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error { // DeleteAllTokenData removes all profile-scoped and legacy token data. func DeleteAllTokenData(configDir string) error { if h := edition.Get(); h.DeleteToken != nil { - return h.DeleteToken(configDir) + return withProfilesLock(configDir, func() error { + return h.DeleteToken(configDir) + }) } return withProfilesLock(configDir, func() error { var firstErr error diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 4cf1bc22..69cd71dd 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -304,3 +304,14 @@ func splitSchemaPathTokens(raw string) []string { } return out } + +// normalizeSchemaQueryCLIPath accepts the historical query spellings while +// keeping authored Registry CLI paths strict and space-separated. Canonical +// identity lookup still runs before this compatibility normalization. +func normalizeSchemaQueryCLIPath(path string) string { + parts := splitSchemaPathTokens(strings.TrimSpace(path)) + if len(parts) > 0 && parts[0] == "dws" { + parts = parts[1:] + } + return strings.Join(parts, " ") +} diff --git a/internal/cli/schema_agent_metadata/event.json b/internal/cli/schema_agent_metadata/event.json index d4d83127..a1730463 100644 --- a/internal/cli/schema_agent_metadata/event.json +++ b/internal/cli/schema_agent_metadata/event.json @@ -2,7 +2,7 @@ "product_id": "event", "tools": { "event consume": { - "agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流", + "agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON", "agent_summary_source": "dws-agent-selection/event", "availability": "available", "avoid_when": [ @@ -13,19 +13,19 @@ "effect": "write", "effect_source": "agent-hint", "examples": [ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson", - "dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson" + "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", + "dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson" ], "field_provenance": { "agent_summary": { - "value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流", + "value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON", "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "candidates": [ { - "value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流", + "value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON", "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", "selected": true, @@ -105,8 +105,8 @@ }, "examples": { "value": [ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson", - "dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson" + "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", + "dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson" ], "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", @@ -115,8 +115,8 @@ "candidates": [ { "value": [ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson", - "dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson" + "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", + "dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson" ], "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", @@ -538,7 +538,7 @@ ] }, "event schema": { - "agent_summary": "查询指定个人事件码的 payload 字段结构", + "agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", "agent_summary_source": "dws-agent-selection/event", "availability": "available", "avoid_when": [ @@ -549,18 +549,18 @@ "effect": "read", "effect_source": "agent-hint", "examples": [ - "dws event schema user_im_message_receive_at --format json" + "dws event schema user_im_message_receive_at --flatten --format json" ], "field_provenance": { "agent_summary": { - "value": "查询指定个人事件码的 payload 字段结构", + "value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "candidates": [ { - "value": "查询指定个人事件码的 payload 字段结构", + "value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", "selected": true, @@ -640,7 +640,7 @@ }, "examples": { "value": [ - "dws event schema user_im_message_receive_at --format json" + "dws event schema user_im_message_receive_at --flatten --format json" ], "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", @@ -649,7 +649,7 @@ "candidates": [ { "value": [ - "dws event schema user_im_message_receive_at --format json" + "dws event schema user_im_message_receive_at --flatten --format json" ], "source": "internal/cli/schema_hints/selection/event.json", "precedence": "reviewed_explicit", diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index b758fbbb..768daa91 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,18 +1,18 @@ { "version": 1, - "source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0", - "surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed", + "source_hash": "sha256:0977e2b3d4af77d348318fd0cd7ce36d325ba9ca4e7c9ed0d6daeb1be0a0c4ec", + "surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5", "coverage": { - "surface_products": 22, - "products_with_metadata": 22, - "surface_tools": 572, - "tools_with_metadata": 572, - "tools_with_agent_summary": 572, - "tools_with_use_when": 572, - "tools_with_avoid_when": 572, - "tools_with_examples": 572, - "tools_with_interface_mode": 572, - "unmatched_skill_tools": 120, + "surface_products": 23, + "products_with_metadata": 23, + "surface_tools": 573, + "tools_with_metadata": 573, + "tools_with_agent_summary": 573, + "tools_with_use_when": 573, + "tools_with_avoid_when": 573, + "tools_with_examples": 573, + "tools_with_interface_mode": 573, + "unmatched_skill_tools": 119, "unreviewed_skill_tools": 7 }, "products": { @@ -1089,6 +1089,74 @@ "查收、搜索、阅读、回复、发送或整理邮件" ] }, + "mcp": { + "agent_summary": "解析和管理当前身份可用的 MCP 服务连接信息", + "agent_summary_source": "dws-agent-selection/mcp", + "avoid_when": [ + "查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp" + ], + "field_provenance": { + "agent_summary": { + "value": "解析和管理当前身份可用的 MCP 服务连接信息", + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "candidates": [ + { + "value": "解析和管理当前身份可用的 MCP 服务连接信息", + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": true + } + ] + }, + "avoid_when": { + "value": [ + "查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "candidates": [ + { + "value": [ + "查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": true + } + ] + }, + "use_when": { + "value": [ + "需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "candidates": [ + { + "value": [ + "需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": true + } + ] + } + }, + "source_refs": [ + "CommandRegistry:product=mcp", + "cobra-help:dws mcp --help", + "internal/cli/schema_command_registry.json#mcp", + "internal/cli/schema_hints/selection/mcp.json" + ], + "use_when": [ + "需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端" + ] + }, "minutes": { "agent_summary": "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。", "agent_summary_source": "dws-agent-selection/minutes", @@ -1605,6 +1673,7 @@ "event", "live", "mail", + "mcp", "minutes", "oa", "pat", diff --git a/internal/cli/schema_agent_metadata/mcp.json b/internal/cli/schema_agent_metadata/mcp.json new file mode 100644 index 00000000..e506fd28 --- /dev/null +++ b/internal/cli/schema_agent_metadata/mcp.json @@ -0,0 +1,267 @@ +{ + "product_id": "mcp", + "tools": { + "mcp url get": { + "agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址", + "agent_summary_source": "dws-agent-selection/mcp", + "availability": "available", + "avoid_when": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ], + "confirmation": "not_required", + "effect": "read", + "effect_source": "agent-hint", + "examples": [ + "dws mcp url get 10043 --format json" + ], + "field_provenance": { + "agent_summary": { + "value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址", + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "candidates": [ + { + "value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址", + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。" + } + ] + }, + "availability": { + "value": "available", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": "available", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + ] + }, + "avoid_when": { + "value": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "candidates": [ + { + "value": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。" + } + ] + }, + "confirmation": { + "value": "not_required", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": "not_required", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + ] + }, + "effect": { + "value": "read", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": "read", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + ] + }, + "examples": { + "value": [ + "dws mcp url get 10043 --format json" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "candidates": [ + { + "value": [ + "dws mcp url get 10043 --format json" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。" + } + ] + }, + "idempotency": { + "value": "idempotent", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": "idempotent", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + ] + }, + "interface_mode": { + "value": "composite", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": "composite", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + ] + }, + "interface_reason": { + "value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + ] + }, + "interface_ref": { + "value": null, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "interface_disposition_matrix", + "review_reason": "final interface mode composite forbids a direct MCP interface_ref", + "candidates": [ + { + "value": null, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "final interface mode composite forbids a direct MCP interface_ref" + } + ] + }, + "reviewed": { + "value": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + }, + { + "value": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": false, + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。" + } + ] + }, + "risk": { + "value": "medium", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "candidates": [ + { + "value": "medium", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + ] + }, + "use_when": { + "value": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "candidates": [ + { + "value": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ], + "source": "internal/cli/schema_hints/selection/mcp.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。" + } + ] + } + }, + "idempotency": "idempotent", + "interface_mode": "composite", + "interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.", + "reviewed": true, + "risk": "medium", + "source_refs": [ + "cobra-help:dws mcp url get --help", + "internal/app/mcp_url_command.go", + "internal/cli/schema_command_registry.json#mcp.url_get", + "internal/cli/schema_hints/metadata/mcp.json", + "internal/cli/schema_hints/selection/mcp.json", + "pkg/edition/default.go#openSupplementServers" + ], + "use_when": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ] + } + } +} diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index 95387b37..d48bb5d5 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,11 +1,11 @@ { "version": 1, - "source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0", - "surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed", - "source_files": 150, - "hint_files": 46, - "hint_products": 39, - "hint_tools": 1296, + "source_hash": "sha256:0977e2b3d4af77d348318fd0cd7ce36d325ba9ca4e7c9ed0d6daeb1be0a0c4ec", + "surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5", + "source_files": 152, + "hint_files": 48, + "hint_products": 40, + "hint_tools": 1298, "interface_metadata": { "source": "mcp-tools-list+cli-registry", "revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8", @@ -27,16 +27,16 @@ ] }, "coverage": { - "surface_products": 22, - "products_with_metadata": 22, - "surface_tools": 572, - "tools_with_metadata": 572, - "tools_with_agent_summary": 572, - "tools_with_use_when": 572, - "tools_with_avoid_when": 572, - "tools_with_examples": 572, - "tools_with_interface_mode": 572, - "unmatched_skill_tools": 120, + "surface_products": 23, + "products_with_metadata": 23, + "surface_tools": 573, + "tools_with_metadata": 573, + "tools_with_agent_summary": 573, + "tools_with_use_when": 573, + "tools_with_avoid_when": 573, + "tools_with_examples": 573, + "tools_with_interface_mode": 573, + "unmatched_skill_tools": 119, "unreviewed_skill_tools": 7 }, "source_products": [ @@ -60,6 +60,7 @@ "generate-topic-report", "live", "mail", + "mcp", "meeting-followup", "minutes", "oa", @@ -1504,38 +1505,10 @@ "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" } }, - { - "tool_path": "chat media upload", - "source": "skills/mono/references/products/chat.md", - "line": 610, - "candidates": [ - "chat bot find", - "chat bot search", - "chat category create-smart" - ], - "review": { - "status": "stale", - "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" - } - }, - { - "tool_path": "chat media upload", - "source": "skills/mono/references/products/chat.md", - "line": 611, - "candidates": [ - "chat bot find", - "chat bot search", - "chat category create-smart" - ], - "review": { - "status": "stale", - "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" - } - }, { "tool_path": "chat message list-emotion-replies", "source": "skills/mono/references/products/chat.md", - "line": 1086, + "line": 1061, "candidates": [ "chat message add-emoji", "chat message add-favorite", @@ -1549,7 +1522,7 @@ { "tool_path": "chat category create", "source": "skills/mono/references/products/chat.md", - "line": 1441, + "line": 1416, "candidates": [ "chat group create", "chat category create-smart", @@ -1563,7 +1536,7 @@ { "tool_path": "chat category delete", "source": "skills/mono/references/products/chat.md", - "line": 1466, + "line": 1441, "candidates": [ "chat category create-smart", "chat category list", @@ -1577,7 +1550,7 @@ { "tool_path": "chat category rename", "source": "skills/mono/references/products/chat.md", - "line": 1477, + "line": 1452, "candidates": [ "chat group rename", "chat category create-smart", @@ -1591,7 +1564,7 @@ { "tool_path": "chat category add-conv", "source": "skills/mono/references/products/chat.md", - "line": 1489, + "line": 1464, "candidates": [ "chat category create-smart", "chat category list", @@ -1605,7 +1578,7 @@ { "tool_path": "chat category remove-conv", "source": "skills/mono/references/products/chat.md", - "line": 1502, + "line": 1477, "candidates": [ "chat category create-smart", "chat category list", @@ -1619,7 +1592,7 @@ { "tool_path": "chat text translate", "source": "skills/mono/references/products/chat.md", - "line": 1517, + "line": 1492, "candidates": [ "chat bot find", "chat bot search", @@ -1633,7 +1606,7 @@ { "tool_path": "chat text translate", "source": "skills/mono/references/products/chat.md", - "line": 1518, + "line": 1493, "candidates": [ "chat bot find", "chat bot search", @@ -1647,7 +1620,7 @@ { "tool_path": "chat text translate", "source": "skills/mono/references/products/chat.md", - "line": 1519, + "line": 1494, "candidates": [ "chat bot find", "chat bot search", @@ -1661,7 +1634,7 @@ { "tool_path": "chat hide", "source": "skills/mono/references/products/chat.md", - "line": 1556, + "line": 1531, "candidates": [ "chat bot find", "chat bot search", @@ -1675,7 +1648,7 @@ { "tool_path": "chat hide", "source": "skills/mono/references/products/chat.md", - "line": 1557, + "line": 1532, "candidates": [ "chat bot find", "chat bot search", @@ -1689,7 +1662,7 @@ { "tool_path": "chat mute-at-all", "source": "skills/mono/references/products/chat.md", - "line": 1579, + "line": 1554, "candidates": [ "chat bot find", "chat bot search", @@ -1703,7 +1676,7 @@ { "tool_path": "chat mute-at-all", "source": "skills/mono/references/products/chat.md", - "line": 1580, + "line": 1555, "candidates": [ "chat bot find", "chat bot search", @@ -1717,7 +1690,7 @@ { "tool_path": "chat mute-red-envelope", "source": "skills/mono/references/products/chat.md", - "line": 1602, + "line": 1577, "candidates": [ "chat bot find", "chat bot search", @@ -1731,7 +1704,7 @@ { "tool_path": "chat mute-red-envelope", "source": "skills/mono/references/products/chat.md", - "line": 1603, + "line": 1578, "candidates": [ "chat bot find", "chat bot search", @@ -1745,7 +1718,7 @@ { "tool_path": "chat mark-unread", "source": "skills/mono/references/products/chat.md", - "line": 1623, + "line": 1598, "candidates": [ "chat bot find", "chat bot search", @@ -1759,7 +1732,7 @@ { "tool_path": "chat mark-unread", "source": "skills/mono/references/products/chat.md", - "line": 1624, + "line": 1599, "candidates": [ "chat bot find", "chat bot search", @@ -1773,7 +1746,7 @@ { "tool_path": "chat clear-red-point", "source": "skills/mono/references/products/chat.md", - "line": 1642, + "line": 1617, "candidates": [ "chat bot find", "chat bot search", @@ -1787,7 +1760,7 @@ { "tool_path": "chat clear-red-point", "source": "skills/mono/references/products/chat.md", - "line": 1643, + "line": 1618, "candidates": [ "chat bot find", "chat bot search", @@ -1801,7 +1774,7 @@ { "tool_path": "chat clear-all-red-point", "source": "skills/mono/references/products/chat.md", - "line": 1659, + "line": 1634, "candidates": [ "chat bot find", "chat bot search", @@ -1815,7 +1788,7 @@ { "tool_path": "chat clear-all-red-point", "source": "skills/mono/references/products/chat.md", - "line": 1661, + "line": 1636, "candidates": [ "chat bot find", "chat bot search", @@ -1829,7 +1802,7 @@ { "tool_path": "chat list-all-conversations", "source": "skills/mono/references/products/chat.md", - "line": 1675, + "line": 1650, "candidates": [ "chat bot find", "chat bot search", @@ -1843,7 +1816,7 @@ { "tool_path": "chat list-all-conversations", "source": "skills/mono/references/products/chat.md", - "line": 1676, + "line": 1651, "candidates": [ "chat bot find", "chat bot search", @@ -1857,7 +1830,7 @@ { "tool_path": "chat list-all-conversations", "source": "skills/mono/references/products/chat.md", - "line": 1677, + "line": 1652, "candidates": [ "chat bot find", "chat bot search", @@ -1871,7 +1844,7 @@ { "tool_path": "chat list-all-conversations", "source": "skills/mono/references/products/chat.md", - "line": 1678, + "line": 1653, "candidates": [ "chat bot find", "chat bot search", @@ -1885,7 +1858,7 @@ { "tool_path": "chat clear-messages", "source": "skills/mono/references/products/chat.md", - "line": 1698, + "line": 1673, "candidates": [ "chat bot find", "chat bot search", @@ -1899,7 +1872,7 @@ { "tool_path": "chat clear-messages", "source": "skills/mono/references/products/chat.md", - "line": 1699, + "line": 1674, "candidates": [ "chat bot find", "chat bot search", @@ -1913,7 +1886,7 @@ { "tool_path": "chat mark-read", "source": "skills/mono/references/products/chat.md", - "line": 1717, + "line": 1692, "candidates": [ "chat bot find", "chat bot search", @@ -1927,7 +1900,7 @@ { "tool_path": "chat mark-read", "source": "skills/mono/references/products/chat.md", - "line": 1718, + "line": 1693, "candidates": [ "chat bot find", "chat bot search", @@ -1941,7 +1914,7 @@ { "tool_path": "chat group list-join-validations", "source": "skills/mono/references/products/chat.md", - "line": 1761, + "line": 1736, "candidates": [ "chat group bots", "chat group create", @@ -1955,7 +1928,7 @@ { "tool_path": "chat group list-join-validations", "source": "skills/mono/references/products/chat.md", - "line": 1762, + "line": 1737, "candidates": [ "chat group bots", "chat group create", @@ -1969,7 +1942,7 @@ { "tool_path": "chat group list-join-validations", "source": "skills/mono/references/products/chat.md", - "line": 1763, + "line": 1738, "candidates": [ "chat group bots", "chat group create", @@ -1983,7 +1956,7 @@ { "tool_path": "chat group audit-join-validation", "source": "skills/mono/references/products/chat.md", - "line": 1783, + "line": 1758, "candidates": [ "chat group bots", "chat group create", @@ -1997,7 +1970,7 @@ { "tool_path": "chat group audit-join-validation", "source": "skills/mono/references/products/chat.md", - "line": 1784, + "line": 1759, "candidates": [ "chat group bots", "chat group create", @@ -2011,7 +1984,7 @@ { "tool_path": "chat group members", "source": "skills/mono/references/products/chat.md", - "line": 1816, + "line": 1791, "candidates": [ "chat group members add", "chat group members add-bot", @@ -2025,7 +1998,7 @@ { "tool_path": "chat group update-alias", "source": "skills/mono/references/products/chat.md", - "line": 1821, + "line": 1796, "candidates": [ "chat group bots", "chat group create", @@ -2039,7 +2012,7 @@ { "tool_path": "chat group update-nick", "source": "skills/mono/references/products/chat.md", - "line": 1822, + "line": 1797, "candidates": [ "chat group bots", "chat group create", @@ -2053,7 +2026,7 @@ { "tool_path": "chat group members list-by-ids", "source": "skills/mono/references/products/chat.md", - "line": 1823, + "line": 1798, "candidates": [ "chat message list-by-ids", "chat group members add", @@ -2067,7 +2040,7 @@ { "tool_path": "chat category create", "source": "skills/mono/references/products/chat.md", - "line": 1847, + "line": 1822, "candidates": [ "chat group create", "chat category create-smart", @@ -2081,7 +2054,7 @@ { "tool_path": "chat category delete", "source": "skills/mono/references/products/chat.md", - "line": 1849, + "line": 1824, "candidates": [ "chat category create-smart", "chat category list", @@ -2095,7 +2068,7 @@ { "tool_path": "chat category rename", "source": "skills/mono/references/products/chat.md", - "line": 1850, + "line": 1825, "candidates": [ "chat group rename", "chat category create-smart", @@ -2109,7 +2082,7 @@ { "tool_path": "chat category add-conv", "source": "skills/mono/references/products/chat.md", - "line": 1851, + "line": 1826, "candidates": [ "chat category create-smart", "chat category list", @@ -2123,7 +2096,7 @@ { "tool_path": "chat category remove-conv", "source": "skills/mono/references/products/chat.md", - "line": 1852, + "line": 1827, "candidates": [ "chat category create-smart", "chat category list", @@ -2137,7 +2110,7 @@ { "tool_path": "chat group share-invite", "source": "skills/mono/references/products/chat.md", - "line": 1863, + "line": 1838, "candidates": [ "chat group bots", "chat group create", @@ -2151,7 +2124,7 @@ { "tool_path": "chat group notice create/edit/get/list", "source": "skills/mono/references/products/chat.md", - "line": 1864, + "line": 1839, "candidates": [ "chat group bots", "chat group create", @@ -2165,7 +2138,7 @@ { "tool_path": "chat message list-emotion-replies", "source": "skills/mono/references/products/chat.md", - "line": 1866, + "line": 1841, "candidates": [ "chat message add-emoji", "chat message add-favorite", @@ -2179,7 +2152,7 @@ { "tool_path": "chat hide", "source": "skills/mono/references/products/chat.md", - "line": 1873, + "line": 1848, "candidates": [ "chat bot find", "chat bot search", @@ -2193,7 +2166,7 @@ { "tool_path": "chat mute-at-all", "source": "skills/mono/references/products/chat.md", - "line": 1874, + "line": 1849, "candidates": [ "chat bot find", "chat bot search", @@ -2207,7 +2180,7 @@ { "tool_path": "chat mute-at-all", "source": "skills/mono/references/products/chat.md", - "line": 1875, + "line": 1850, "candidates": [ "chat bot find", "chat bot search", @@ -2221,7 +2194,7 @@ { "tool_path": "chat mute-red-envelope", "source": "skills/mono/references/products/chat.md", - "line": 1876, + "line": 1851, "candidates": [ "chat bot find", "chat bot search", @@ -2235,7 +2208,7 @@ { "tool_path": "chat mute-red-envelope", "source": "skills/mono/references/products/chat.md", - "line": 1877, + "line": 1852, "candidates": [ "chat bot find", "chat bot search", @@ -2249,7 +2222,7 @@ { "tool_path": "chat mark-unread", "source": "skills/mono/references/products/chat.md", - "line": 1878, + "line": 1853, "candidates": [ "chat bot find", "chat bot search", @@ -2263,7 +2236,7 @@ { "tool_path": "chat mark-read", "source": "skills/mono/references/products/chat.md", - "line": 1879, + "line": 1854, "candidates": [ "chat bot find", "chat bot search", @@ -2277,7 +2250,7 @@ { "tool_path": "chat clear-red-point", "source": "skills/mono/references/products/chat.md", - "line": 1880, + "line": 1855, "candidates": [ "chat bot find", "chat bot search", @@ -2291,7 +2264,7 @@ { "tool_path": "chat clear-all-red-point", "source": "skills/mono/references/products/chat.md", - "line": 1881, + "line": 1856, "candidates": [ "chat bot find", "chat bot search", @@ -2305,7 +2278,7 @@ { "tool_path": "chat list-all-conversations", "source": "skills/mono/references/products/chat.md", - "line": 1882, + "line": 1857, "candidates": [ "chat bot find", "chat bot search", @@ -2319,7 +2292,7 @@ { "tool_path": "chat clear-messages", "source": "skills/mono/references/products/chat.md", - "line": 1883, + "line": 1858, "candidates": [ "chat bot find", "chat bot search", @@ -2333,7 +2306,7 @@ { "tool_path": "chat group list-join-validations", "source": "skills/mono/references/products/chat.md", - "line": 1885, + "line": 1860, "candidates": [ "chat group bots", "chat group create", @@ -2347,7 +2320,7 @@ { "tool_path": "chat group audit-join-validation", "source": "skills/mono/references/products/chat.md", - "line": 1886, + "line": 1861, "candidates": [ "chat group bots", "chat group create", @@ -2358,24 +2331,10 @@ "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" } }, - { - "tool_path": "chat media upload", - "source": "skills/mono/references/products/chat.md", - "line": 1893, - "candidates": [ - "chat bot find", - "chat bot search", - "chat category create-smart" - ], - "review": { - "status": "stale", - "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" - } - }, { "tool_path": "chat text translate", "source": "skills/mono/references/products/chat.md", - "line": 1905, + "line": 1881, "candidates": [ "chat bot find", "chat bot search", diff --git a/internal/cli/schema_catalog.go b/internal/cli/schema_catalog.go index ac3fee54..47037f50 100644 --- a/internal/cli/schema_catalog.go +++ b/internal/cli/schema_catalog.go @@ -361,7 +361,7 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) ( return payload, nil } raw := strings.TrimSpace(args[0]) - if tool, ok := loaded.Index.Resolve(raw); ok { + if tool, ok := loaded.Index.ResolveQuery(raw); ok { return schemaToolForResolvedPath(tool, raw).ToPayload() } tokens := splitSchemaPathTokens(raw) diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index bf675dc4..fdfb7d25 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,21 +1,21 @@ { "version": 1, - "surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed", - "source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b", + "surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5", + "source_hash": "sha256:360114d03cc3e8dc6cef30c6e617cc40f88e5577c29dd5b5946fe1cde91eb9de", "catalog": { "agent_metadata": { - "products_with_metadata": 22, + "products_with_metadata": 23, "source": "embedded-skill-metadata", - "source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0", - "surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed", - "surface_products": 22, - "surface_tools": 572, - "tools_with_agent_summary": 572, - "tools_with_metadata": 572, - "unmatched_skill_tools": 120, + "source_hash": "sha256:0977e2b3d4af77d348318fd0cd7ce36d325ba9ca4e7c9ed0d6daeb1be0a0c4ec", + "surface_hash": "sha256:e85148774f65d34b01d50ebdc702b785afdb8ea78663b6f2eeecc35bc3cb3fd5", + "surface_products": 23, + "surface_tools": 573, + "tools_with_agent_summary": 573, + "tools_with_metadata": 573, + "unmatched_skill_tools": 119, "version": 1 }, - "count": 22, + "count": 23, "interface_metadata": { "coverage": { "aliased_tools": 178, @@ -7660,7 +7660,7 @@ "cli_name": "send", "cli_path": "chat message send", "confirmation": "not_required", - "description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送", + "description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。", "effect": "write", "group": "message", "idempotency": "unknown", @@ -12178,7 +12178,7 @@ "tools": [ { "agent_metadata_source": "embedded-skill-metadata", - "agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流", + "agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON", "agent_summary_source": "dws-agent-selection/event", "availability": "available", "avoid_when": [ @@ -12189,7 +12189,7 @@ "cli_name": "consume", "cli_path": "event consume", "confirmation": "not_required", - "description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。", + "description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。", "effect": "write", "idempotency": "non_idempotent", "interface_mode": "composite", @@ -12234,7 +12234,7 @@ }, { "agent_metadata_source": "embedded-skill-metadata", - "agent_summary": "查询指定个人事件码的 payload 字段结构", + "agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", "agent_summary_source": "dws-agent-selection/event", "availability": "available", "avoid_when": [ @@ -13476,6 +13476,110 @@ "查收、搜索、阅读、回复、发送或整理邮件" ] }, + { + "agent_metadata_source": "embedded-skill-metadata", + "agent_source_refs": [ + "CommandRegistry:product=mcp", + "cobra-help:dws mcp --help", + "internal/cli/schema_command_registry.json#mcp", + "internal/cli/schema_hints/selection/mcp.json" + ], + "agent_summary": "解析和管理当前身份可用的 MCP 服务连接信息", + "agent_summary_source": "dws-agent-selection/mcp", + "avoid_when": [ + "查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp" + ], + "description": "管理 MCP 服务连接信息", + "field_provenance": { + "agent_summary": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "selected": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": "解析和管理当前身份可用的 MCP 服务连接信息" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": "解析和管理当前身份可用的 MCP 服务连接信息" + }, + "avoid_when": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "selected": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp" + ] + }, + "use_when": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "selected": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端" + ] + } + }, + "id": "mcp", + "name": "管理 MCP 服务连接信息", + "runtime": true, + "tool_count": 1, + "tools": [ + { + "agent_metadata_source": "embedded-skill-metadata", + "agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址", + "agent_summary_source": "dws-agent-selection/mcp", + "availability": "available", + "avoid_when": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ], + "canonical_path": "mcp.url_get", + "cli_name": "get", + "cli_path": "mcp url get", + "confirmation": "not_required", + "description": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。", + "effect": "read", + "group": "url", + "idempotency": "idempotent", + "interface_mode": "composite", + "interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.", + "name": "url_get", + "primary_cli_path": "mcp url get", + "reviewed": true, + "risk": "medium", + "title": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址", + "use_when": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ] + } + ], + "use_when": [ + "需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端" + ] + }, { "agent_metadata_source": "embedded-skill-metadata", "agent_source_refs": [ @@ -19153,6 +19257,6 @@ } ], "source": "embedded-command-catalog", - "tool_count": 572 + "tool_count": 573 } } diff --git a/internal/cli/schema_catalog/tools/chat.json b/internal/cli/schema_catalog/tools/chat.json index 1aea91f9..fda7ed8f 100644 --- a/internal/cli/schema_catalog/tools/chat.json +++ b/internal/cli/schema_catalog/tools/chat.json @@ -42170,7 +42170,7 @@ ] ] }, - "description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送", + "description": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。", "display": "群聊 / 消息 / 机器人", "effect": "write", "effect_source": "agent-hint", @@ -42287,7 +42287,7 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送" + "value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。" }, { "precedence": "mcp_metadata", @@ -42299,7 +42299,7 @@ "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n富媒体消息(通过 --msg-type 指定类型):\n image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得)\n file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送" + "value": "以当前用户身份发送消息。\n\n⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。\n\n目标选择(三选一,必填):\n --group 群聊 openconversation_id\n --user 单聊接收人 userId\n --open-dingtalk-id 单聊接收人 openDingTalkId\n\n纯文本 / Markdown 消息(默认):\n 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。\n\n本地图片 / 文件消息:\n 统一使用 --msg-type file --file-path \u003c本地路径\u003e。CLI 会完成上传并按 file 消息发送;\n .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。\n\n旧版内联图片消息:\n 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。\n 当前 CLI 不提供本地文件到 mediaId 的上传能力。" }, "effect": { "candidates": [ @@ -42863,7 +42863,7 @@ "type": "string" }, "file-path": { - "description": "本地文件路径(msgType=file 时可直接上传发送)", + "description": "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)", "field_provenance": { "description": { "candidates": [ @@ -42871,7 +42871,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "本地文件路径(msgType=file 时可直接上传发送)" + "value": "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)" }, { "precedence": "default", @@ -42883,7 +42883,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "本地文件路径(msgType=file 时可直接上传发送)" + "value": "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)" }, "format": { "candidates": [ @@ -43085,7 +43085,7 @@ "type": "string" }, "media-id": { - "description": "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)", + "description": "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)", "example": "@lADP_schema_smoke", "field_provenance": { "description": { @@ -43094,7 +43094,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)" + "value": "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)" }, { "precedence": "default", @@ -43106,7 +43106,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)" + "value": "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)" }, "example": { "candidates": [ @@ -43204,7 +43204,7 @@ "type": "string" }, "msg-type": { - "description": "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)", + "description": "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)", "enum": [ "image", "file", @@ -43218,7 +43218,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)" + "value": "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)" }, { "precedence": "mcp_metadata", @@ -43236,7 +43236,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)" + "value": "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)" }, "enum": { "candidates": [ diff --git a/internal/cli/schema_catalog/tools/event.json b/internal/cli/schema_catalog/tools/event.json index a4475088..728330b3 100644 --- a/internal/cli/schema_catalog/tools/event.json +++ b/internal/cli/schema_catalog/tools/event.json @@ -13,7 +13,7 @@ "skills/mono/SKILL.md", "skills/mono/references/products/event.md" ], - "agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流", + "agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON", "agent_summary_source": "dws-agent-selection/event", "availability": "available", "avoid_when": [ @@ -32,13 +32,13 @@ ] ] }, - "description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。", + "description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。", "display": "事件订阅 (DingTalk Stream 长连接)", "effect": "write", "effect_source": "agent-hint", "examples": [ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson", - "dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson" + "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", + "dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson" ], "field_provenance": { "agent_summary": { @@ -48,14 +48,14 @@ "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "selected": true, "source": "internal/cli/schema_hints/selection/event.json", - "value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流" + "value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON" } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "source": "internal/cli/schema_hints/selection/event.json", - "value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流" + "value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON" }, "availability": { "candidates": [ @@ -133,13 +133,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。" + "value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。" + "value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。" }, "effect": { "candidates": [ @@ -165,8 +165,8 @@ "selected": true, "source": "internal/cli/schema_hints/selection/event.json", "value": [ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson", - "dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson" + "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", + "dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson" ] } ], @@ -175,8 +175,8 @@ "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "source": "internal/cli/schema_hints/selection/event.json", "value": [ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson", - "dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson" + "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", + "dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson" ] }, "idempotency": { @@ -327,7 +327,7 @@ "interface_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.", "is_alias": false, "name": "consume", - "parameter_count": 27, + "parameter_count": 28, "parameters": { "compact": { "description": "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)", @@ -1007,6 +1007,90 @@ "required": false, "type": "string" }, + "flatten": { + "description": "将个人事件 transport envelope 投影为稳定的顶层业务字段", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "将个人事件 transport envelope 投影为稳定的顶层业务字段" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "将个人事件 transport envelope 投影为稳定的顶层业务字段" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "flatten" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "flatten" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "property": "flatten", + "required": false, + "type": "boolean" + }, "force": { "description": "仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)", "field_provenance": { @@ -3347,7 +3431,7 @@ "skills/mono/SKILL.md", "skills/mono/references/products/event.md" ], - "agent_summary": "查询指定个人事件码的 payload 字段结构", + "agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", "agent_summary_source": "dws-agent-selection/event", "availability": "available", "avoid_when": [ @@ -3363,7 +3447,7 @@ "effect": "read", "effect_source": "agent-hint", "examples": [ - "dws event schema user_im_message_receive_at --format json" + "dws event schema user_im_message_receive_at --flatten --format json" ], "field_provenance": { "agent_summary": { @@ -3373,14 +3457,14 @@ "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "selected": true, "source": "internal/cli/schema_hints/selection/event.json", - "value": "查询指定个人事件码的 payload 字段结构" + "value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式" } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "source": "internal/cli/schema_hints/selection/event.json", - "value": "查询指定个人事件码的 payload 字段结构" + "value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式" }, "availability": { "candidates": [ @@ -3490,7 +3574,7 @@ "selected": true, "source": "internal/cli/schema_hints/selection/event.json", "value": [ - "dws event schema user_im_message_receive_at --format json" + "dws event schema user_im_message_receive_at --flatten --format json" ] } ], @@ -3499,7 +3583,7 @@ "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", "source": "internal/cli/schema_hints/selection/event.json", "value": [ - "dws event schema user_im_message_receive_at --format json" + "dws event schema user_im_message_receive_at --flatten --format json" ] }, "idempotency": { @@ -3646,8 +3730,92 @@ "interface_reason": "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC", "is_alias": false, "name": "schema", - "parameter_count": 1, + "parameter_count": 2, "parameters": { + "flatten": { + "description": "显示 --flatten 消费模式对应的顶层业务字段 schema", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "显示 --flatten 消费模式对应的顶层业务字段 schema" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "显示 --flatten 消费模式对应的顶层业务字段 schema" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "flatten" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "flatten" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "property": "flatten", + "required": false, + "type": "boolean" + }, "format": { "default": "json", "description": "输出格式: json", diff --git a/internal/cli/schema_catalog/tools/mcp.json b/internal/cli/schema_catalog/tools/mcp.json new file mode 100644 index 00000000..a04411f7 --- /dev/null +++ b/internal/cli/schema_catalog/tools/mcp.json @@ -0,0 +1,337 @@ +{ + "product": "mcp", + "tools": { + "mcp.url_get": { + "agent_metadata_source": "embedded-skill-metadata", + "agent_source_refs": [ + "cobra-help:dws mcp url get --help", + "internal/app/mcp_url_command.go", + "internal/cli/schema_command_registry.json#mcp.url_get", + "internal/cli/schema_hints/metadata/mcp.json", + "internal/cli/schema_hints/selection/mcp.json", + "pkg/edition/default.go#openSupplementServers" + ], + "agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址", + "agent_summary_source": "dws-agent-selection/mcp", + "availability": "available", + "avoid_when": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ], + "canonical_path": "mcp.url_get", + "cli_name": "get", + "cli_path": "mcp url get", + "confirmation": "not_required", + "description": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。", + "display": "管理 MCP 服务连接信息", + "effect": "read", + "effect_source": "agent-hint", + "examples": [ + "dws mcp url get 10043 --format json" + ], + "field_provenance": { + "agent_summary": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "selected": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址" + }, + "availability": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "available" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "available" + }, + "avoid_when": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "selected": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ] + }, + "canonical_path": { + "candidates": [ + { + "precedence": "command_registry", + "selected": true, + "source": "reviewed_command_registry", + "source_ref": "mcp url get", + "value": "mcp.url_get" + } + ], + "precedence": "command_registry", + "resolution": "registry_identity", + "source": "reviewed_command_registry", + "source_ref": "mcp url get", + "value": "mcp.url_get" + }, + "confirmation": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "not_required" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "not_required" + }, + "description": { + "candidates": [ + { + "precedence": "cobra_help", + "selected": true, + "source": "cobra_help", + "value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。" + } + ], + "precedence": "cobra_help", + "resolution": "highest_precedence", + "source": "cobra_help", + "value": "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 Streamable HTTP 服务地址。\n\n安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用,请勿分享到群聊、文档、邮件、代码仓库或日志。" + }, + "effect": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "read" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "read" + }, + "examples": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "selected": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "dws mcp url get 10043 --format json" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "dws mcp url get 10043 --format json" + ] + }, + "idempotency": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "idempotent" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "idempotent" + }, + "interface_mode": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "composite" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "composite" + }, + "interface_reason": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata." + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata." + }, + "interface_ref": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "final interface mode composite forbids a direct MCP interface_ref", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": null + } + ], + "precedence": "reviewed_explicit", + "resolution": "interface_disposition_matrix", + "review_reason": "final interface mode composite forbids a direct MCP interface_ref", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": null + }, + "reviewed": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": true + }, + { + "precedence": "reviewed_explicit", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "selected": false, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": true + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": true + }, + "risk": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "medium" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output.", + "source": "internal/cli/schema_hints/metadata/mcp.json", + "value": "medium" + }, + "title": { + "candidates": [ + { + "precedence": "cobra_help", + "selected": true, + "source": "cobra_help", + "value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址" + } + ], + "precedence": "cobra_help", + "resolution": "highest_precedence", + "source": "cobra_help", + "value": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址" + }, + "use_when": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "selected": true, + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "source": "internal/cli/schema_hints/selection/mcp.json", + "value": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ] + } + }, + "group": "url", + "has_parameters": false, + "idempotency": "idempotent", + "interface_mode": "composite", + "interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.", + "is_alias": false, + "name": "url_get", + "parameter_count": 0, + "parameters": {}, + "path": "mcp.url_get", + "positionals": [ + { + "description": "钉钉 MCP 市场中的 mcpId", + "index": 0, + "name": "mcp_id", + "required": true, + "type": "string" + } + ], + "primary_cli_path": "mcp url get", + "product_id": "mcp", + "reviewed": true, + "risk": "medium", + "source": "reviewed_command_registry", + "title": "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址", + "use_when": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ] + } + } +} diff --git a/internal/cli/schema_catalog_coverage_more_test.go b/internal/cli/schema_catalog_coverage_more_test.go index a2e5d4c3..8baf0538 100644 --- a/internal/cli/schema_catalog_coverage_more_test.go +++ b/internal/cli/schema_catalog_coverage_more_test.go @@ -194,6 +194,23 @@ func TestCrossPlatformCoverageSchemaCatalogLookupAndConversionEdges(t *testing.T } } +func TestEmbeddedSchemaLookupAcceptsCompatibleCLIPathSeparators(t *testing.T) { + loaded := embeddedSchemaCatalog() + for _, path := range []string{ + "dev app list", + "dev.app.list", + "dev/app/list", + } { + payload, err := schemaPayloadFromLoadedCatalog(loaded, []string{path}) + if err != nil { + t.Fatalf("schemaPayloadFromLoadedCatalog(%q) error = %v", path, err) + } + if got := schemaString(payload["canonical_path"]); got != "dev.list_dev_app" { + t.Fatalf("schemaPayloadFromLoadedCatalog(%q) canonical_path = %q, want %q", path, got, "dev.list_dev_app") + } + } +} + type catalogHookSnapshot struct { parameterBindings func(BoundCommandRegistry, SchemaRegistry) error dryRun func(SchemaRegistry) error diff --git a/internal/cli/schema_command_exclusions.json b/internal/cli/schema_command_exclusions.json index 60d555fc..3858e7ca 100644 --- a/internal/cli/schema_command_exclusions.json +++ b/internal/cli/schema_command_exclusions.json @@ -321,7 +321,6 @@ "chat list-all-conversations", "chat mark-read", "chat mark-unread", - "chat media upload", "chat message list-emotion-replies", "chat message set-top-msg", "chat message unset-top-msg", diff --git a/internal/cli/schema_command_registry/products/mcp.json b/internal/cli/schema_command_registry/products/mcp.json new file mode 100644 index 00000000..e1474ea4 --- /dev/null +++ b/internal/cli/schema_command_registry/products/mcp.json @@ -0,0 +1,9 @@ +{ + "id": "mcp", + "tools": [ + { + "canonical_path": "mcp.url_get", + "cli_path": "mcp url get" + } + ] +} diff --git a/internal/cli/schema_contract_model.go b/internal/cli/schema_contract_model.go index ccaa239b..51170479 100644 --- a/internal/cli/schema_contract_model.go +++ b/internal/cli/schema_contract_model.go @@ -617,6 +617,22 @@ func (i SchemaIndex) Resolve(path string) (ToolSpec, bool) { return i.registry.Products[location.product].Tools[location.tool], true } +// ResolveQuery adds compatibility for dotted and slash-separated CLI paths at +// the user-facing query boundary. Resolve remains strict because Registry +// validation uses it to detect missing canonical identities without falling +// through to a similarly spelled CLI path. +func (i SchemaIndex) ResolveQuery(path string) (ToolSpec, bool) { + if tool, ok := i.Resolve(path); ok { + return tool, true + } + canonical, ok := i.byCLIPath[normalizeSchemaQueryCLIPath(path)] + if !ok { + return ToolSpec{}, false + } + location := i.byCanonical[canonical] + return i.registry.Products[location.product].Tools[location.tool], true +} + // CanonicalPaths returns the complete tool identity set in stable order. func (i SchemaIndex) CanonicalPaths() []string { paths := make([]string, 0, len(i.byCanonical)) diff --git a/internal/cli/schema_contract_model_test.go b/internal/cli/schema_contract_model_test.go index 5abbbcf3..d82491a7 100644 --- a/internal/cli/schema_contract_model_test.go +++ b/internal/cli/schema_contract_model_test.go @@ -514,6 +514,19 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) { t.Fatalf("Resolve(%q) = %#v, %v", path, resolved.Identity, ok) } } + for _, path := range []string{ + "calendar.attendee.delete", + "calendar/attendee/delete", + "dws.calendar.attendee.delete", + } { + resolved, ok := index.ResolveQuery(path) + if !ok || resolved.Identity.CanonicalPath != "calendar.attendee_delete" { + t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok) + } + } + if _, ok := index.ResolveQuery("calendar.attendee.unknown"); ok { + t.Fatal("unknown dotted CLI path unexpectedly resolved") + } if got := index.CanonicalPaths(); !reflect.DeepEqual(got, []string{"calendar.attendee_delete"}) { t.Fatalf("CanonicalPaths() = %#v", got) } diff --git a/internal/cli/schema_delivery_invariants_test.go b/internal/cli/schema_delivery_invariants_test.go index 043637af..b819efd9 100644 --- a/internal/cli/schema_delivery_invariants_test.go +++ b/internal/cli/schema_delivery_invariants_test.go @@ -154,6 +154,35 @@ func TestSelectionExplicitEmptyListSurvivesFinalDelivery(t *testing.T) { } } +func TestCompatibleSchemaAliasSeparatorsSurviveFinalDelivery(t *testing.T) { + snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{ + Canonical: "sample.run", + CLIPath: "sample category run", + Aliases: []string{"sample legacy execute"}, + }) + encoded, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + loaded, err := decodeSchemaCatalogSnapshot(encoded) + if err != nil { + t.Fatalf("decodeSchemaCatalogSnapshot(): %v", err) + } + canonical, err := schemaPayloadFromLoadedCatalog(loaded, []string{"sample.run"}) + if err != nil { + t.Fatalf("canonical query: %v", err) + } + for _, path := range []string{"sample legacy execute", "sample.legacy.execute", "sample/legacy/execute"} { + alias, aliasErr := schemaPayloadFromLoadedCatalog(loaded, []string{path}) + if aliasErr != nil { + t.Fatalf("alias query %q: %v", path, aliasErr) + } + if problem := schemaAliasViewProblem(canonical, alias, "sample legacy execute"); problem != "" { + t.Fatalf("alias projection for %q: %s", path, problem) + } + } +} + func TestValidateSchemaDeliveryInvariantsAllowsOnlyEnvelopeHashes(t *testing.T) { snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{Canonical: "sample.run", CLIPath: "sample run"}) snapshot.SurfaceHash = "sha256:reviewed-command-registry" diff --git a/internal/cli/schema_full_pipeline_external_test.go b/internal/cli/schema_full_pipeline_external_test.go index 5d6a35bd..3694fdb2 100644 --- a/internal/cli/schema_full_pipeline_external_test.go +++ b/internal/cli/schema_full_pipeline_external_test.go @@ -16,7 +16,7 @@ import ( // Go's normal per-package coverage accounting attributes the exercised Schema // assembly code to internal/cli. func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) { - root := app.NewRootCommand() + root := app.NewSchemaSourceRootCommand() resolved, err := cli.ResolveSchemaBuild(root) if err != nil { t.Fatalf("ResolveSchemaBuild() error = %v", err) @@ -33,17 +33,17 @@ func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) { if len(snapshot.Tools) == 0 { t.Fatal("production Schema snapshot contains no tools") } - registry, err := cli.AssembleSchemaRegistry(app.NewRootCommand()) + registry, err := cli.AssembleSchemaRegistry(app.NewSchemaSourceRootCommand()) if err != nil { t.Fatalf("AssembleSchemaRegistry() error = %v", err) } if len(registry.Products) == 0 { t.Fatal("assembled production Schema registry contains no products") } - if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewRootCommand()); err != nil { + if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil { t.Fatalf("ValidateEmbeddedRuntimeSchemaCompleteness() error = %v", err) } - root = app.NewRootCommand() + root = app.NewSchemaSourceRootCommand() if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil { t.Fatal(err) } diff --git a/internal/cli/schema_hints/index.json b/internal/cli/schema_hints/index.json index de218051..7e220cd6 100644 --- a/internal/cli/schema_hints/index.json +++ b/internal/cli/schema_hints/index.json @@ -23,6 +23,7 @@ "event": "metadata/event.json", "live": "metadata/live.json", "mail": "metadata/mail.json", + "mcp": "metadata/mcp.json", "minutes": "metadata/minutes.json", "oa": "metadata/oa.json", "pat": "metadata/pat.json", @@ -47,6 +48,7 @@ "event": "selection/event.json", "live": "selection/live.json", "mail": "selection/mail.json", + "mcp": "selection/mcp.json", "minutes": "selection/minutes.json", "oa": "selection/oa.json", "pat": "selection/pat.json", @@ -434,10 +436,6 @@ "status": "stale", "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" }, - "chat media upload": { - "status": "stale", - "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" - }, "chat message list-emotion-replies": { "status": "stale", "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" @@ -634,6 +632,11 @@ "target": "event consume", "reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf" }, + "event consume user_im_message_receive_at": { + "status": "alias", + "target": "event consume", + "reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf" + }, "event consume user_im_message_receive_group": { "status": "alias", "target": "event consume", diff --git a/internal/cli/schema_hints/metadata/mcp.json b/internal/cli/schema_hints/metadata/mcp.json new file mode 100644 index 00000000..9b8e39c2 --- /dev/null +++ b/internal/cli/schema_hints/metadata/mcp.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "source": { + "kind": "explicit", + "name": "dws-tool-metadata/mcp", + "repository": "DingTalk-Real-AI/dingtalk-workspace-cli", + "channel": "open-source", + "reviewed": true + }, + "tools": { + "mcp.url_get": { + "effect": "read", + "risk": "medium", + "confirmation": "not_required", + "idempotency": "idempotent", + "interface_mode": "composite", + "availability": "available", + "interface_reason": "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.", + "runtime_gate": "none", + "reviewed": true, + "review_reason": "Expose the reviewed MCP URL resolver through the public CLI and Agent Schema while preserving the helper endpoint as a non-product supplemental server. The returned URL contains user- and organization-scoped credentials and must be treated as sensitive output." + } + } +} diff --git a/internal/cli/schema_hints/reference-review.json b/internal/cli/schema_hints/reference-review.json index 9cc9e2da..62a9fb03 100644 --- a/internal/cli/schema_hints/reference-review.json +++ b/internal/cli/schema_hints/reference-review.json @@ -385,10 +385,6 @@ "status": "stale", "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" }, - "chat media upload": { - "status": "stale", - "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" - }, "chat message list-emotion-replies": { "status": "stale", "reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射" @@ -585,6 +581,11 @@ "target": "event consume", "reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf" }, + "event consume user_im_message_receive_at": { + "status": "alias", + "target": "event consume", + "reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf" + }, "event consume user_im_message_receive_group": { "status": "alias", "target": "event consume", diff --git a/internal/cli/schema_hints/runtime-surface-completeness.json b/internal/cli/schema_hints/runtime-surface-completeness.json index 11fed0ed..17227d9e 100644 --- a/internal/cli/schema_hints/runtime-surface-completeness.json +++ b/internal/cli/schema_hints/runtime-surface-completeness.json @@ -7,7 +7,7 @@ "channel": "open-source" }, "coverage": { - "source_tools": 572, + "source_tools": 573, "matched_tools": 71 }, "tools": { diff --git a/internal/cli/schema_hints/selection/event.json b/internal/cli/schema_hints/selection/event.json index ecb9b813..d33ac30f 100644 --- a/internal/cli/schema_hints/selection/event.json +++ b/internal/cli/schema_hints/selection/event.json @@ -9,7 +9,7 @@ }, "tools": { "event.consume": { - "agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流", + "agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON", "use_when": [ "需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件", "需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件", @@ -20,8 +20,8 @@ "只看事件目录/字段时用 event list / event schema" ], "examples": [ - "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson", - "dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson" + "dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson", + "dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson" ], "reviewed": true, "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", @@ -57,7 +57,7 @@ ] }, "event.schema": { - "agent_summary": "查询指定个人事件码的 payload 字段结构", + "agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", "use_when": [ "已知任一公开个人消息 event_key,消费前需要理解扁平输出字段" ], @@ -66,7 +66,7 @@ "要实际收事件时用 event consume" ], "examples": [ - "dws event schema user_im_message_receive_at --format json" + "dws event schema user_im_message_receive_at --flatten --format json" ], "reviewed": true, "review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。", diff --git a/internal/cli/schema_hints/selection/mcp.json b/internal/cli/schema_hints/selection/mcp.json new file mode 100644 index 00000000..9f642132 --- /dev/null +++ b/internal/cli/schema_hints/selection/mcp.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "source": { + "kind": "explicit", + "name": "dws-agent-selection/mcp", + "repository": "DingTalk-Real-AI/dingtalk-workspace-cli", + "channel": "open-source", + "reviewed": true + }, + "tools": { + "mcp.url_get": { + "agent_summary": "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址", + "use_when": [ + "已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址" + ], + "avoid_when": [ + "只是查询 DWS 已公开命令或参数时使用 dws schema", + "用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播" + ], + "examples": [ + "dws mcp url get 10043 --format json" + ], + "reviewed": true, + "review_reason": "正式公开 MCP URL 解析命令的 Agent 选型文案,并明确凭据 URL 只能返回给当前用户、不得二次传播。", + "source_refs": [ + "internal/cli/schema_command_registry.json#mcp.url_get", + "cobra-help:dws mcp url get --help", + "internal/app/mcp_url_command.go", + "pkg/edition/default.go#openSupplementServers" + ] + } + }, + "products": { + "mcp": { + "agent_summary": "解析和管理当前身份可用的 MCP 服务连接信息", + "use_when": [ + "需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端" + ], + "avoid_when": [ + "查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp" + ], + "reviewed": true, + "review_reason": "为公开 mcp 命令提供产品级 Agent 路由,并强调连接信息属于敏感凭据。", + "source_refs": [ + "internal/cli/schema_command_registry.json#mcp", + "cobra-help:dws mcp --help", + "CommandRegistry:product=mcp" + ] + } + } +} diff --git a/internal/cli/schema_runtime_registry.go b/internal/cli/schema_runtime_registry.go index 95390b19..46a59207 100644 --- a/internal/cli/schema_runtime_registry.go +++ b/internal/cli/schema_runtime_registry.go @@ -284,7 +284,7 @@ func runtimeSchemaPayloadFromRegistry(registry SchemaRegistry, args []string) (m } raw := strings.TrimSpace(args[0]) - if tool, ok := index.Resolve(raw); ok { + if tool, ok := index.ResolveQuery(raw); ok { tool = schemaToolForResolvedPath(tool, raw) return renderRegistryToolPayload(tool) } @@ -338,7 +338,7 @@ func runtimeSchemaAllPayloadFromRegistry(registry SchemaRegistry) (map[string]an } func schemaToolForResolvedPath(tool ToolSpec, raw string) ToolSpec { - normalized := normalizeSchemaCLIPath(raw) + normalized := normalizeSchemaQueryCLIPath(raw) if normalized == "" || normalized == tool.Identity.CLIPath || normalized == tool.Identity.PrimaryCLIPath { return tool } diff --git a/internal/event/bus/coverage_edges_test.go b/internal/event/bus/coverage_edges_test.go index 24761bc2..6b4faeae 100644 --- a/internal/event/bus/coverage_edges_test.go +++ b/internal/event/bus/coverage_edges_test.go @@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) { d.conns.Store("not-a-conn", struct{}{}) d.conns.Store(&queryConn{}, struct{}{}) d.consumerWG.Add(1) - d.shutdown() + acceptDone := make(chan struct{}) + close(acceptDone) + d.shutdown(acceptDone) d.consumerWG.Done() - d.shutdown() + d.shutdown(acceptDone) pr, pw, err := os.Pipe() if err != nil { diff --git a/internal/event/bus/daemon.go b/internal/event/bus/daemon.go index d85bd46d..7d3ad911 100644 --- a/internal/event/bus/daemon.go +++ b/internal/event/bus/daemon.go @@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error { } // 7. Graceful shutdown — cancel runCtx first so all background - // goroutines wake up, then close listener / drain consumers. + // goroutines wake up, then stop accepting connections before draining + // consumers. The accept-loop barrier is required before WaitGroup.Wait: + // sync.WaitGroup forbids a positive Add racing with Wait. cancelRun() - d.shutdown() - <-acceptDone + d.shutdown(acceptDone) <-idleDone <-dropWarnDone @@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) { d.log.Warn("bus: accept error", "err", err) continue } + // Track the connection before publishing the handler goroutine. Once + // acceptLoop returns, shutdown can therefore close every accepted + // connection before waiting for handlers to drain. + d.conns.Store(conn, struct{}{}) d.consumerWG.Add(1) go func() { defer d.consumerWG.Done() @@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) { // Hello → register with Hub → spawn writer goroutine → read until EOF/Bye. // Always Unregisters and Closes on exit (plan invariant #5). func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) { - d.conns.Store(conn, struct{}{}) defer func() { d.conns.Delete(conn) conn.Close() @@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) { // 1. mark shuttingDown so acceptLoop exits cleanly // 2. broadcast Bye to all consumers // 3. close listener (interrupts pending Accept) -// 4. wait for all per-connection goroutines to drain -// 5. lock + meta cleanup via Run's defers -func (d *daemon) shutdown() { +// 4. wait for acceptLoop to return so no future consumerWG.Add can occur +// 5. close all accepted connections and wait for handlers to drain +// 6. lock + meta cleanup via Run's defers +func (d *daemon) shutdown(acceptDone <-chan struct{}) { d.shutdownMu.Lock() defer d.shutdownMu.Unlock() if !d.shuttingDown.CompareAndSwap(false, true) { @@ -488,6 +493,7 @@ func (d *daemon) shutdown() { } d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}) _ = d.listener.Close() + <-acceptDone // Force-close all open IPC connections so any reader goroutine blocked // on Read() returns with a network error and exits cleanly. Without // this the consumerWG never drains and Run hangs forever. diff --git a/internal/event/consume/integration_test.go b/internal/event/consume/integration_test.go index 5408a259..50b7d206 100644 --- a/internal/event/consume/integration_test.go +++ b/internal/event/consume/integration_test.go @@ -31,6 +31,31 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" ) +type consumeReadyWriter struct { + ready chan struct{} + once sync.Once +} + +func newConsumeReadyWriter() *consumeReadyWriter { + return &consumeReadyWriter{ready: make(chan struct{})} +} + +func (w *consumeReadyWriter) Write(p []byte) (int, error) { + if bytes.Contains(p, []byte("[event] ready ")) { + w.once.Do(func() { close(w.ready) }) + } + return len(p), nil +} + +func waitForConsumerReady(t *testing.T, ready <-chan struct{}) { + t.Helper() + select { + case <-ready: + case <-time.After(3 * time.Second): + t.Fatal("consumer did not report ready") + } +} + // TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time // event_types pushdown contract (plan §4 unsung superpower): a consumer // subscribing to "im.*" must NOT receive "approval.*" events even when @@ -49,40 +74,50 @@ func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) { defer func() { cancel(); <-runDone }() var imBuf, approvalBuf bytes.Buffer - var wg sync.WaitGroup - wg.Add(2) + imReady := newConsumeReadyWriter() + approvalReady := newConsumeReadyWriter() + consumeCtx, cancelConsumers := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelConsumers() + consumeDone := make(chan error, 2) // Consumer A: im.* only go func() { - defer wg.Done() - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &imBuf, - Stderr: io.Discard, + Stderr: imReady, EventTypes: []string{"im.*"}, MaxEvents: 3, // 3 im events expected }) }() // Consumer B: approval.* only go func() { - defer wg.Done() - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &approvalBuf, - Stderr: io.Discard, + Stderr: approvalReady, EventTypes: []string{"approval.*"}, MaxEvents: 2, // 2 approval events expected }) }() - // Give both consumers time to Hello + register. - time.Sleep(200 * time.Millisecond) + waitForConsumerReady(t, imReady.ready) + waitForConsumerReady(t, approvalReady.ready) close(trigger) - wg.Wait() + for range 2 { + select { + case err := <-consumeDone: + if err != nil { + t.Fatalf("consume failed: %v", err) + } + case <-consumeCtx.Done(): + t.Fatalf("consumers did not finish: %v", consumeCtx.Err()) + } + } // Verify consumer A got exactly the 3 im.* events. imLines := nonEmptyLines(imBuf.String()) @@ -130,23 +165,32 @@ func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) { defer func() { cancel(); <-runDone }() var buf bytes.Buffer - consumeDone := make(chan struct{}) + ready := newConsumeReadyWriter() + consumeCtx, cancelConsumer := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelConsumer() + consumeDone := make(chan error, 1) go func() { - defer close(consumeDone) - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &buf, - Stderr: io.Discard, + Stderr: ready, EventTypes: []string{"im.*"}, Filter: `\.at_v1$`, // only at_v1 events MaxEvents: 1, }) }() - time.Sleep(150 * time.Millisecond) + waitForConsumerReady(t, ready.ready) close(trigger) - <-consumeDone + select { + case err := <-consumeDone: + if err != nil { + t.Fatalf("consume failed: %v", err) + } + case <-consumeCtx.Done(): + t.Fatalf("consumer did not finish: %v", consumeCtx.Err()) + } lines := nonEmptyLines(buf.String()) if len(lines) != 1 { diff --git a/internal/event/consume/run.go b/internal/event/consume/run.go index d9620fdb..65ceb58c 100644 --- a/internal/event/consume/run.go +++ b/internal/event/consume/run.go @@ -90,6 +90,10 @@ type Config struct { // NormalizeFormat; an empty Format here defaults to NDJSON inside // BuildPipeline. Format Format + // Flatten records whether the caller explicitly selected a structured + // business projection. Projector remains the executable behavior; this + // field is surfaced in dry-run output so users can verify the final mode. + Flatten bool // OutputDir, if non-empty, switches the fallback sink from stdout to // "file per event" under this directory. OutputDir string diff --git a/internal/event/consume/validate.go b/internal/event/consume/validate.go index 33eafe0b..1982f1eb 100644 --- a/internal/event/consume/validate.go +++ b/internal/event/consume/validate.go @@ -132,6 +132,7 @@ func PrintDryRun(w io.Writer, cfg Config) { fmt.Fprintf(w, " filter : %s\n", cfg.Filter) } fmt.Fprintf(w, " format : %s\n", cfg.Format) + fmt.Fprintf(w, " flatten : %v\n", cfg.Flatten) if cfg.OutputDir != "" { fmt.Fprintf(w, " output_dir : %s\n", cfg.OutputDir) } diff --git a/internal/event/consume/validate_test.go b/internal/event/consume/validate_test.go index 4da7dff0..2f5b64a4 100644 --- a/internal/event/consume/validate_test.go +++ b/internal/event/consume/validate_test.go @@ -137,6 +137,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) { c.EventTypes = []string{"im.*", "approval.*"} c.Filter = "^im\\." c.Format = FormatCompact + c.Flatten = true c.OutputDir = "/tmp/events" c.Routes, _ = ParseRoutes([]string{`^im\.=dir:/tmp/im/`}) c.MaxEvents = 5 @@ -151,7 +152,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) { wants := []string{ "client_id", "workdir", "ipc_endpoint", "im.*,approval.*", "^im\\.", "compact", "/tmp/events", "route[0]", "max_events : 5", - "duration", "true", + "duration", "flatten : true", "true", } for _, w := range wants { if !strings.Contains(out, w) { diff --git a/internal/event/personal/client.go b/internal/event/personal/client.go index 5f973f22..5c7ab042 100644 --- a/internal/event/personal/client.go +++ b/internal/event/personal/client.go @@ -63,9 +63,10 @@ func (i Identity) Key() string { } type Client struct { - BaseURL string - HTTPClient *http.Client - Identity Identity + BaseURL string + HTTPClient *http.Client + Identity Identity + AccessTokenProvider func(context.Context) (string, error) } type CreateSubscriptionRequest struct { @@ -337,8 +338,9 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body if c == nil { return errors.New("personal event: nil client") } - if c.Identity.AccessToken == "" { - return errors.New("personal event: access token is required") + accessToken, err := c.resolveAccessToken(ctx) + if err != nil { + return err } u := strings.TrimRight(c.BaseURL, "/") + path if len(q) > 0 { @@ -358,7 +360,7 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body if err != nil { return fmt.Errorf("personal event: create request: %w", err) } - c.decorate(req) + c.decorate(req, accessToken) if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -424,9 +426,26 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body return json.Unmarshal(data, out) } -func (c *Client) decorate(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken) - req.Header.Set("x-user-access-token", c.Identity.AccessToken) +func (c *Client) resolveAccessToken(ctx context.Context) (string, error) { + if c.AccessTokenProvider != nil { + token, err := c.AccessTokenProvider(ctx) + if err != nil { + return "", fmt.Errorf("personal event: resolve access token: %w", err) + } + if token = strings.TrimSpace(token); token != "" { + return token, nil + } + return "", errors.New("personal event: access token provider returned empty token") + } + if token := strings.TrimSpace(c.Identity.AccessToken); token != "" { + return token, nil + } + return "", errors.New("personal event: access token is required") +} + +func (c *Client) decorate(req *http.Request, accessToken string) { + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("x-user-access-token", accessToken) req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID) req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID) if c.Identity.CorpID != "" { diff --git a/internal/event/personal/output.go b/internal/event/personal/output.go index c2a344dc..e880b538 100644 --- a/internal/event/personal/output.go +++ b/internal/event/personal/output.go @@ -394,6 +394,39 @@ func outputSchema(eventKey string) map[string]any { } } +func transportEnvelopeSchema(eventKey string) map[string]any { + eventType := reflect.TypeOf(transport.Event{}) + properties := make(map[string]any, eventType.NumField()) + for i := 0; i < eventType.NumField(); i++ { + field := eventType.Field(i) + name := strings.Split(field.Tag.Get("json"), ",")[0] + property := map[string]any{"type": schemaType(field.Type)} + switch name { + case "type": + property["description"] = "transport frame 类型" + property["enum"] = []string{string(transport.FrameTypeEvent)} + case "event_type": + property["description"] = "事件类型" + property["enum"] = []string{eventKey} + case "data": + property["description"] = "服务端业务 payload JSON 字符串" + property["content_media_type"] = "application/json" + case "headers": + property["description"] = "Stream transport headers" + property["additionalProperties"] = map[string]any{"type": "string"} + case "event_id": + property["description"] = "transport 事件 ID" + case "subscribe_id": + property["description"] = "个人事件订阅 ID" + } + properties[name] = property + } + return map[string]any{ + "type": "object", + "properties": properties, + } +} + func outputTypeForEvent(eventKey string) reflect.Type { switch { case isMessageReceiveEvent(eventKey): diff --git a/internal/event/personal/registry.go b/internal/event/personal/registry.go index 5f0da590..a2857a86 100644 --- a/internal/event/personal/registry.go +++ b/internal/event/personal/registry.go @@ -258,8 +258,18 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition { } func BuildSchemaDocument(def Definition) SchemaDocument { + return BuildSchemaDocumentForMode(def, false) +} + +func BuildSchemaDocumentForMode(def Definition, flatten bool) SchemaDocument { requiredParams := make([]string, 0, len(def.RequiredParams)) requiredParams = append(requiredParams, def.RequiredParams...) + jqRootPath := ".data | fromjson" + schema := transportEnvelopeSchema(def.EventKey) + if flatten { + jqRootPath = "." + schema = outputSchema(def.EventKey) + } return SchemaDocument{ EventKey: def.EventKey, DisplayName: def.DisplayName, @@ -268,8 +278,8 @@ func BuildSchemaDocument(def Definition) SchemaDocument { RuleType: def.RuleType, RequiredParams: requiredParams, Constraints: cloneParameterConstraints(def.Constraints), - JQRootPath: ".", - Schema: outputSchema(def.EventKey), + JQRootPath: jqRootPath, + Schema: schema, } } diff --git a/internal/event/personal/registry_test.go b/internal/event/personal/registry_test.go index a5beaa23..affcd43b 100644 --- a/internal/event/personal/registry_test.go +++ b/internal/event/personal/registry_test.go @@ -95,7 +95,7 @@ func TestDefinitionJSONHidesInternalSchemaIDs(t *testing.T) { } } -func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { +func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) { for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} { t.Run(eventKey, func(t *testing.T) { def, ok := Lookup(eventKey) @@ -117,16 +117,16 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { "required_params", "jq_root_path", "schema", + "type", + "seq", "event_id", - "timestamp", + "event_born_time", + "event_type", "subscribe_id", - "content", - "sender", - "sender_open_dingtalk_id", - "conversation_id", - "message_id", - "create_time", - "event_time", + "source_id", + "data", + "headers", + "received_at_unix_ms", } { if !strings.Contains(out, want) { t.Fatalf("schema for %s missing %q: %s", eventKey, want, out) @@ -144,7 +144,6 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { "payload_schema", "output_schema", "data_json_path", - "headers", "audit", "tenant", "subject", @@ -152,13 +151,18 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { "msgIdMetaq", "at_users", "sender_user_id", + "sender_open_dingtalk_id", + "conversation_id", + "message_id", + "create_time", + "event_time", } { if strings.Contains(out, leaked) { t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out) } } - if doc.JQRootPath != "." { - t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath) + if doc.JQRootPath != ".data | fromjson" { + t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath) } if doc.RequiredParams == nil { t.Fatalf("required_params = nil, want empty slice") @@ -167,6 +171,38 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { if !ok { t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"]) } + wantProperties := []string{ + "type", "seq", "event_id", "event_born_time", "event_corp_id", + "event_type", "event_unified_app_id", "event_scope", "subscribe_id", + "source_id", "rule_type", "data", "headers", "received_at_unix_ms", + } + if len(props) != len(wantProperties) { + t.Fatalf("schema.properties = %#v, want exactly %d transport fields", props, len(wantProperties)) + } + for _, name := range wantProperties { + if _, ok := props[name].(map[string]any); !ok { + t.Fatalf("schema.properties.%s = %#v, want object", name, props[name]) + } + } + }) + } +} + +func TestFlattenedSchemaDocumentsUseMessageDTO(t *testing.T) { + for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} { + t.Run(eventKey, func(t *testing.T) { + def, ok := Lookup(eventKey) + if !ok { + t.Fatalf("Lookup(%q) failed", eventKey) + } + doc := BuildSchemaDocumentForMode(def, true) + if doc.JQRootPath != "." { + t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath) + } + props, ok := doc.Schema["properties"].(map[string]any) + if !ok { + t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"]) + } wantProperties := []string{ "type", "event_id", "timestamp", "subscribe_id", "message_id", "conversation_id", "sender", "sender_open_dingtalk_id", "content", @@ -180,6 +216,11 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { t.Fatalf("schema.properties.%s = %#v, want object", name, props[name]) } } + for _, transportField := range []string{"data", "headers", "seq", "event_type"} { + if _, ok := props[transportField]; ok { + t.Fatalf("flattened schema exposed transport field %q", transportField) + } + } }) } } @@ -286,7 +327,7 @@ func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) { if !ok { t.Fatalf("Lookup(%q) failed", eventKey) } - doc := BuildSchemaDocument(def) + doc := BuildSchemaDocumentForMode(def, true) if doc.JQRootPath != "." { t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath) } diff --git a/internal/event/personal/token_provider_test.go b/internal/event/personal/token_provider_test.go new file mode 100644 index 00000000..25667ba4 --- /dev/null +++ b/internal/event/personal/token_provider_test.go @@ -0,0 +1,56 @@ +package personal + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type accessTokenRoundTripper func(*http.Request) (*http.Response, error) + +func (f accessTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestCrossPlatformCoverageClientResolvesAccessTokenPerRequest(t *testing.T) { + tokens := []string{"token-a", "token-b"} + calls := 0 + client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"}) + client.AccessTokenProvider = func(context.Context) (string, error) { + token := tokens[calls] + calls++ + return token, nil + } + client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(req *http.Request) (*http.Response, error) { + want := tokens[calls-1] + if got := req.Header.Get("Authorization"); got != "Bearer "+want { + t.Fatalf("Authorization = %q, want Bearer %s", got, want) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[]}}`)), Header: make(http.Header)}, nil + })} + for range 2 { + if _, err := client.ListSubscriptions(context.Background(), ListOptions{}); err != nil { + t.Fatal(err) + } + } + if calls != 2 { + t.Fatalf("provider calls = %d, want 2", calls) + } +} + +func TestCrossPlatformCoverageClientDoesNotFallBackAfterProviderFailure(t *testing.T) { + want := errors.New("keychain failed") + client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"}) + client.AccessTokenProvider = func(context.Context) (string, error) { return "", want } + client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(*http.Request) (*http.Response, error) { + t.Fatal("HTTP must not run after token provider failure") + return nil, nil + })} + _, err := client.ListSubscriptions(context.Background(), ListOptions{}) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } +} diff --git a/internal/event/source/coverage_edges_test.go b/internal/event/source/coverage_edges_test.go index 9849ef74..59381253 100644 --- a/internal/event/source/coverage_edges_test.go +++ b/internal/event/source/coverage_edges_test.go @@ -414,7 +414,7 @@ func TestCrossPlatformCoveragePortalStartEndToEndAndFailures(t *testing.T) { return s } network := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errSourceInjected })} - s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network}) + s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network, DisableReconnect: true}) if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil { t.Fatal("ticket failure expected") } @@ -483,7 +483,7 @@ func TestCrossPlatformCoveragePortalStartHandshakeReadAndAckErrors(t *testing.T) makeSource := func(endpoint string) *DingtalkSource { s, err := New(Config{PortalTicket: &PortalTicketConfig{ TicketURL: "https://ticket", AccessToken: "token", SourceID: "source", - HTTPClient: staticPersonalTicketClient(endpoint, ""), + HTTPClient: staticPersonalTicketClient(endpoint, ""), DisableReconnect: true, }}) if err != nil { t.Fatal(err) diff --git a/internal/event/source/personal.go b/internal/event/source/personal.go index 1d4233a6..c461c17b 100644 --- a/internal/event/source/personal.go +++ b/internal/event/source/personal.go @@ -27,6 +27,7 @@ import ( "sync/atomic" "time" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/gorilla/websocket" @@ -41,19 +42,30 @@ const ( ) type PersonalConfig struct { - AccessToken string - ClientID string - ClientSecret string - SourceID string - TicketURL string - TicketMode string - HTTPClient *http.Client - WebSocketDialer *websocket.Dialer - Now func() time.Time - ReconnectMin time.Duration - ReconnectMax time.Duration + AccessToken string + AccessTokenProvider AccessTokenProvider + ForceRefreshToken ForceRefreshTokenFn + ClientID string + ClientSecret string + SourceID string + TicketURL string + TicketMode string + HTTPClient *http.Client + WebSocketDialer *websocket.Dialer + Now func() time.Time + ReconnectMin time.Duration + ReconnectMax time.Duration } +type AccessTokenProvider func(context.Context) (string, error) + +// ForceRefreshTokenFn rotates an access token that the server has just +// rejected (HTTP 401). It receives the exact rejected token so the caller's +// compare-and-refresh logic can skip the refresh when another goroutine has +// already rotated it, and returns the fresh token to retry with. Optional: +// when nil a 401 stays fatal, matching the previous behavior. +type ForceRefreshTokenFn func(ctx context.Context, rejectedToken string) (string, error) + type PersonalSource struct { cfg PersonalConfig machine *Machine @@ -73,8 +85,8 @@ type ticketResponse struct { } func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) { - if strings.TrimSpace(cfg.AccessToken) == "" { - return nil, errors.New("personal source: AccessToken is required") + if cfg.AccessTokenProvider == nil && strings.TrimSpace(cfg.AccessToken) == "" { + return nil, errors.New("personal source: AccessToken or AccessTokenProvider is required") } if strings.TrimSpace(cfg.ClientID) == "" { return nil, errors.New("personal source: ClientID is required") @@ -192,6 +204,31 @@ func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) ( } func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) { + accessToken, err := resolveSourceAccessToken(ctx, s.cfg.AccessTokenProvider, s.cfg.AccessToken, "personal source") + if err != nil { + // Transient provider failures (network, 429, 5xx) must not kill a + // long-running source; the reconnect loop retries after backoff. + if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient { + return nil, retryPersonal(err) + } + return nil, err + } + ticket, status, err := s.fetchTicketAttempt(ctx, accessToken) + if status == http.StatusUnauthorized && s.cfg.ForceRefreshToken != nil { + refreshed, refreshErr := refreshRejectedSourceToken(ctx, s.cfg.ForceRefreshToken, accessToken, "personal source", err) + if refreshErr != nil { + if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient { + return nil, retryPersonal(refreshErr) + } + return nil, refreshErr + } + // Retry once with the freshly rotated token; a second 401 stays fatal. + ticket, _, err = s.fetchTicketAttempt(ctx, refreshed) + } + return ticket, err +} + +func (s *PersonalSource) fetchTicketAttempt(ctx context.Context, accessToken string) (*ticketResponse, int, error) { body := map[string]any{ "sourceId": s.cfg.SourceID, "mode": s.cfg.TicketMode, @@ -203,39 +240,78 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro b, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TicketURL, bytes.NewReader(b)) if err != nil { - return nil, fmt.Errorf("personal source: create ticket request: %w", err) + return nil, 0, fmt.Errorf("personal source: create ticket request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("x-user-access-token", s.cfg.AccessToken) - req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken) + req.Header.Set("x-user-access-token", accessToken) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID) req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID) resp, err := s.cfg.HTTPClient.Do(req) if err != nil { - return nil, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err)) + return nil, 0, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err)) } defer resp.Body.Close() - data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) - if err != nil { - return nil, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err)) - } if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Classify by status before touching the body: a truncated error body + // must not upgrade a fatal status (notably 401) into a retryable + // error, or the outer reconnect loop would bypass the single + // refresh-retry guard. The body is not used here, so drain it only + // best-effort for connection reuse. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, config.MaxResponseBodySize)) err := fmt.Errorf("personal source: ticket HTTP %d", resp.StatusCode) if retryableTicketStatus(resp.StatusCode) { - return nil, retryPersonal(err) + return nil, resp.StatusCode, retryPersonal(err) } - return nil, err + return nil, resp.StatusCode, err + } + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, resp.StatusCode, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err)) } ticket, err := decodeTicket(data) if err != nil { - return nil, err + return nil, resp.StatusCode, err } if ticket.Endpoint == "" || ticket.Ticket == "" { - return nil, errors.New("personal source: ticket response missing endpoint or ticket") + return nil, resp.StatusCode, errors.New("personal source: ticket response missing endpoint or ticket") } - return ticket, nil + return ticket, resp.StatusCode, nil +} + +// refreshRejectedSourceToken funnels a server-side 401 into the optional +// force-refresh callback. It hands the actual rejected token to the caller's +// compare-and-refresh logic and returns the rotated token for an immediate +// retry. Refresh failures keep the original 401 as context instead of being +// dropped. +func refreshRejectedSourceToken(ctx context.Context, refresh ForceRefreshTokenFn, rejectedToken, component string, cause error) (string, error) { + token, err := refresh(ctx, rejectedToken) + if err != nil { + return "", fmt.Errorf("%s: refresh rejected access token: %w", component, errors.Join(cause, err)) + } + if token = strings.TrimSpace(token); token == "" { + return "", fmt.Errorf("%s: refresh rejected access token returned empty token: %w", component, cause) + } + return token, nil +} + +func resolveSourceAccessToken(ctx context.Context, provider AccessTokenProvider, fallback, component string) (string, error) { + if provider != nil { + token, err := provider(ctx) + if err != nil { + return "", fmt.Errorf("%s: resolve access token: %w", component, err) + } + if token = strings.TrimSpace(token); token != "" { + return token, nil + } + return "", fmt.Errorf("%s: access token provider returned empty token", component) + } + if token := strings.TrimSpace(fallback); token != "" { + return token, nil + } + return "", fmt.Errorf("%s: access token is required", component) } func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error { @@ -357,6 +433,13 @@ func isRetryablePersonalError(err error) bool { func personalRetryLogError(err error) string { message := err.Error() switch { + case strings.Contains(message, "resolve access token"), strings.Contains(message, "refresh rejected access token"): + // Token resolution/refresh errors may carry provider details; log + // only the structured HTTP status. + if status := refreshHTTPStatus(err); status != 0 { + return fmt.Sprintf("personal source: token refresh HTTP %d", status) + } + return "personal source: token refresh: temporary network error" case strings.Contains(message, "ticket HTTP"): return message case strings.Contains(message, "fetch ticket"): @@ -374,6 +457,14 @@ func personalRetryLogError(err error) string { } } +func refreshHTTPStatus(err error) int { + var statusErr *authpkg.HTTPStatusError + if !errors.As(err, &statusErr) || statusErr == nil { + return 0 + } + return statusErr.StatusCode +} + func retryableTicketStatus(status int) bool { return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || diff --git a/internal/event/source/portal_ticket.go b/internal/event/source/portal_ticket.go index 7f5444e9..148bb76d 100644 --- a/internal/event/source/portal_ticket.go +++ b/internal/event/source/portal_ticket.go @@ -20,11 +20,13 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "net/url" "strings" "time" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" "github.com/gorilla/websocket" "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" @@ -33,20 +35,56 @@ import ( const ( PortalTicketModeNormal = "normal" PortalTicketModeCustom = "custom" + portalReconnectMin = time.Second + portalReconnectMax = 30 * time.Second ) // PortalTicketConfig describes the portal-managed user Stream ticket flow. // normal mode uses portal-side managed credentials; custom mode asks portal to // open the user connection with the caller-provided clientId/clientSecret. type PortalTicketConfig struct { - TicketURL string - AccessToken string - SourceID string - Mode string - ClientID string - ClientSecret string - UserAgent string - HTTPClient *http.Client + TicketURL string + AccessToken string + AccessTokenProvider AccessTokenProvider + ForceRefreshToken ForceRefreshTokenFn + SourceID string + Mode string + ClientID string + ClientSecret string + UserAgent string + HTTPClient *http.Client + WebSocketDialer *websocket.Dialer + ReconnectMin time.Duration + ReconnectMax time.Duration + DisableReconnect bool +} + +// portalStageError tags a portal stream failure with the stage it happened +// in and whether the reconnect loop may retry it. Error() stays free of +// untrusted response content so it is safe to log on every reconnect. +type portalStageError struct { + stage string + status int + retryable bool + cause error +} + +func (e *portalStageError) Error() string { + if e == nil { + return "source: portal stream failed" + } + message := "source: portal " + strings.ReplaceAll(strings.TrimSpace(e.stage), "_", " ") + " failed" + if e.status != 0 { + message += fmt.Sprintf(" (HTTP %d)", e.status) + } + return message +} + +func (e *portalStageError) Unwrap() error { + if e == nil { + return nil + } + return e.cause } var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error { @@ -60,8 +98,8 @@ func (c *PortalTicketConfig) Valid() error { if strings.TrimSpace(c.TicketURL) == "" { return errors.New("source: portal ticket URL is required") } - if strings.TrimSpace(c.AccessToken) == "" { - return errors.New("source: portal access token is required") + if c.AccessTokenProvider == nil && strings.TrimSpace(c.AccessToken) == "" { + return errors.New("source: portal access token or provider is required") } if strings.TrimSpace(c.SourceID) == "" { return errors.New("source: portal sourceId is required") @@ -96,48 +134,102 @@ func normalizePortalTicketMode(mode string) string { func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.EmitFn) error { s.machine.OnConnecting() + defer s.machine.OnStopped() + minBackoff := s.cfg.PortalTicket.ReconnectMin + if minBackoff <= 0 { + minBackoff = portalReconnectMin + } + maxBackoff := s.cfg.PortalTicket.ReconnectMax + if maxBackoff <= 0 { + maxBackoff = portalReconnectMax + } + if maxBackoff < minBackoff { + maxBackoff = minBackoff + } + backoff := minBackoff + for { + acked, err := s.runPortalTicketAttempt(ctx, emit) + if ctx.Err() != nil { + return ctx.Err() + } + var stageErr *portalStageError + if !errors.As(err, &stageErr) || stageErr == nil || !stageErr.retryable || s.cfg.PortalTicket.DisableReconnect { + return err + } + if acked { + backoff = minBackoff + } + s.machine.OnReconnect() + slog.Warn("portal source reconnecting", + "stage", stageErr.stage, + "http_status", stageErr.status, + "error_type", fmt.Sprintf("%T", stageErr.cause), + "retry_in", backoff, + "reconnect_count", s.machine.Snapshot().ReconnectCount, + ) + if err := waitPersonalReconnect(ctx, backoff); err != nil { + return err + } + backoff = nextPersonalBackoff(backoff, maxBackoff) + } +} + +func (s *DingtalkSource) runPortalTicketAttempt(ctx context.Context, emit dwsevent.EmitFn) (bool, error) { ticket, err := requestPortalTicket(ctx, s.cfg.PortalTicket) if err != nil { - s.machine.OnStopped() - return err + return false, err } wsURL, err := websocketURL(ticket) if err != nil { - s.machine.OnStopped() - return err + return false, err } userAgent := strings.TrimSpace(s.cfg.PortalTicket.UserAgent) if userAgent == "" { userAgent = "dws-event-consume" } - conn, resp, err := (&websocket.Dialer{HandshakeTimeout: 20 * time.Second}).DialContext(ctx, wsURL, http.Header{ + dialer := s.cfg.PortalTicket.WebSocketDialer + if dialer == nil { + dialer = &websocket.Dialer{HandshakeTimeout: 20 * time.Second} + } + conn, resp, err := dialer.DialContext(ctx, wsURL, http.Header{ "User-Agent": []string{userAgent}, }) if err != nil { - s.machine.OnStopped() + status := 0 + cause := fmt.Errorf("source: portal stream connect: %w", err) if resp != nil { defer resp.Body.Close() + status = resp.StatusCode raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - return fmt.Errorf("source: portal stream connect HTTP %d: %s: %w", + cause = fmt.Errorf("source: portal stream connect HTTP %d: %s: %w", resp.StatusCode, truncatePortalTicketLog(string(raw), 300), err) } - return fmt.Errorf("source: portal stream connect: %w", err) + return false, &portalStageError{ + stage: "stream_connect", + status: status, + retryable: status == 0 || retryableTicketStatus(status), + cause: cause, + } } - defer conn.Close() + attemptCtx, cancel := context.WithCancel(ctx) + defer func() { + cancel() + _ = conn.Close() + }() + closeOnContext(attemptCtx, conn) s.machine.OnConnected() - closeOnContext(ctx, conn) handler := s.makeHandler(emit) + acked := false for { _, message, err := conn.ReadMessage() if err != nil { - s.machine.OnStopped() if isContextDone(ctx) { - return ctx.Err() + return acked, ctx.Err() } - return fmt.Errorf("source: portal stream read: %w", err) + return acked, &portalStageError{stage: "stream_read", retryable: true, cause: fmt.Errorf("source: portal stream read: %w", err)} } df, err := payload.DecodeDataFrame(message) if err != nil { @@ -146,12 +238,12 @@ func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.Em resp, _ := handler(ctx, df) ensurePortalAckHeaders(resp, df) if err := portalWriteMessage(conn, websocket.TextMessage, resp.Encode()); err != nil { - s.machine.OnStopped() if isContextDone(ctx) { - return ctx.Err() + return acked, ctx.Err() } - return fmt.Errorf("source: portal stream ack: %w", err) + return acked, &portalStageError{stage: "stream_ack", retryable: true, cause: fmt.Errorf("source: portal stream ack: %w", err)} } + acked = true } } @@ -161,10 +253,35 @@ type portalStreamTicket struct { } func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) { + accessToken, err := resolveSourceAccessToken(ctx, cfg.AccessTokenProvider, cfg.AccessToken, "source: portal ticket") + if err != nil { + // Transient provider failures (network, 429, 5xx) must not kill a + // long-running source; the reconnect loop retries after backoff. + if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient { + return portalStreamTicket{}, &portalStageError{stage: "ticket_auth", status: refreshHTTPStatus(err), retryable: true, cause: err} + } + return portalStreamTicket{}, err + } httpClient := cfg.HTTPClient if httpClient == nil { httpClient = &http.Client{Timeout: 20 * time.Second} } + ticket, status, err := requestPortalTicketAttempt(ctx, cfg, httpClient, accessToken) + if status == http.StatusUnauthorized && cfg.ForceRefreshToken != nil { + refreshed, refreshErr := refreshRejectedSourceToken(ctx, cfg.ForceRefreshToken, accessToken, "source: portal ticket", err) + if refreshErr != nil { + if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient { + return portalStreamTicket{}, &portalStageError{stage: "ticket_auth_refresh", status: refreshHTTPStatus(refreshErr), retryable: true, cause: refreshErr} + } + return portalStreamTicket{}, refreshErr + } + // Retry once with the freshly rotated token; a second 401 stays fatal. + ticket, _, err = requestPortalTicketAttempt(ctx, cfg, httpClient, refreshed) + } + return ticket, err +} + +func requestPortalTicketAttempt(ctx context.Context, cfg *PortalTicketConfig, httpClient *http.Client, accessToken string) (portalStreamTicket, int, error) { body := map[string]string{ "sourceId": strings.TrimSpace(cfg.SourceID), "channelType": strings.TrimSpace(cfg.SourceID), @@ -177,29 +294,45 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt rawBody, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.TicketURL), bytes.NewReader(rawBody)) if err != nil { - return portalStreamTicket{}, err + return portalStreamTicket{}, 0, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if ua := strings.TrimSpace(cfg.UserAgent); ua != "" { req.Header.Set("User-Agent", ua) } - req.Header.Set("x-user-access-token", cfg.AccessToken) + req.Header.Set("x-user-access-token", accessToken) resp, err := httpClient.Do(req) if err != nil { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket request: %w", err) + return portalStreamTicket{}, 0, &portalStageError{stage: "ticket_request", retryable: true, cause: fmt.Errorf("source: portal ticket request: %w", err)} } defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode >= 400 { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket HTTP %d: %s", + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Preserve HTTP status semantics before touching the body. A truncated + // 401 body must not become a retryable read error that bypasses the + // single token-refresh guard; the body is only best-effort diagnostics. + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + httpErr := fmt.Errorf("source: portal ticket HTTP %d: %s", resp.StatusCode, truncatePortalTicketLog(string(raw), 300)) + if retryableTicketStatus(resp.StatusCode) { + return portalStreamTicket{}, resp.StatusCode, &portalStageError{stage: "ticket_request", status: resp.StatusCode, retryable: true, cause: httpErr} + } + return portalStreamTicket{}, resp.StatusCode, httpErr + } + raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return portalStreamTicket{}, resp.StatusCode, &portalStageError{ + stage: "ticket_request", + status: resp.StatusCode, + retryable: true, + cause: fmt.Errorf("source: portal ticket read: %w", readErr), + } } var direct portalStreamTicket if err := json.Unmarshal(raw, &direct); err == nil && direct.Endpoint != "" && direct.Ticket != "" { - return direct, nil + return direct, resp.StatusCode, nil } var envelope struct { @@ -209,16 +342,16 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt ErrorMsg string `json:"errorMsg"` } if err := json.Unmarshal(raw, &envelope); err != nil { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket parse: %w", err) + return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket parse: %w", err) } if !envelope.Success { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket failed: %s %s", + return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket failed: %s %s", envelope.ErrorCode, envelope.ErrorMsg) } if envelope.Result.Endpoint == "" || envelope.Result.Ticket == "" { - return portalStreamTicket{}, errors.New("source: portal ticket result missing endpoint/ticket") + return portalStreamTicket{}, resp.StatusCode, errors.New("source: portal ticket result missing endpoint/ticket") } - return envelope.Result, nil + return envelope.Result, resp.StatusCode, nil } func websocketURL(ticket portalStreamTicket) (string, error) { diff --git a/internal/event/source/portal_ticket_refresh_test.go b/internal/event/source/portal_ticket_refresh_test.go new file mode 100644 index 00000000..374755cf --- /dev/null +++ b/internal/event/source/portal_ticket_refresh_test.go @@ -0,0 +1,485 @@ +package source + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/gorilla/websocket" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +// TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd drives the full production chain +// DingtalkSource.Start → startPortalTicket → requestPortalTicket: the first +// ticket request is rejected with 401, ForceRefreshToken rotates the token, +// the in-chain retry succeeds with the fresh token and a WebSocket event is +// delivered to emit. +func TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd(t *testing.T) { + var ticketCalls, refreshCalls atomic.Int64 + var rejectedSeen atomic.Value + + upgrader := websocket.Upgrader{} + var wsURL string + mux := http.NewServeMux() + mux.HandleFunc("/ticket", func(w http.ResponseWriter, r *http.Request) { + ticketCalls.Add(1) + switch r.Header.Get("x-user-access-token") { + case "fresh-token": + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]string{"endpoint": wsURL, "ticket": "ticket-1"}, + }) + default: + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, "token expired") + } + }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "msg-1"}, Data: `{}`} + _ = conn.WriteJSON(df) + _, _, _ = conn.ReadMessage() + }) + srv := httptest.NewServer(mux) + defer srv.Close() + wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + + s, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: srv.URL + "/ticket", + AccessTokenProvider: func(context.Context) (string, error) { + return "stale-token", nil + }, + ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) { + refreshCalls.Add(1) + rejectedSeen.Store(rejectedToken) + return "fresh-token", nil + }, + SourceID: "source", + HTTPClient: srv.Client(), + }}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + emitted := make(chan struct{}, 1) + done := make(chan error, 1) + go func() { done <- s.Start(ctx, func(*dwsevent.RawEvent) { emitted <- struct{}{} }) }() + select { + case <-emitted: + case <-time.After(2 * time.Second): + t.Fatal("portal event timeout after 401 refresh retry") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("portal stop = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("portal stop timeout") + } + + if got := ticketCalls.Load(); got != 2 { + t.Fatalf("ticket calls = %d, want 2", got) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want 1", got) + } + if got, _ := rejectedSeen.Load().(string); got != "stale-token" { + t.Fatalf("rejected token = %q, want %q", got, "stale-token") + } +} + +// TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly asserts the in-chain +// retry sends the token returned by ForceRefreshToken instead of re-invoking +// the provider (which could still serve the stale token). +func TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly(t *testing.T) { + providerCalls := 0 + var attemptTokens []string + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + token := req.Header.Get("x-user-access-token") + attemptTokens = append(attemptTokens, token) + if token != "rotated" { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://x","ticket":"t"}`)), Header: make(http.Header)}, nil + })} + ticket, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessTokenProvider: func(context.Context) (string, error) { + providerCalls++ + return "stale", nil + }, + ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) { + if rejectedToken != "stale" { + t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale") + } + return "rotated", nil + }, + SourceID: "s", + HTTPClient: client, + }) + if err != nil { + t.Fatalf("requestPortalTicket = %v", err) + } + if ticket.Endpoint != "wss://x" || ticket.Ticket != "t" { + t.Fatalf("ticket = %#v", ticket) + } + if providerCalls != 1 { + t.Fatalf("provider calls = %d, want 1", providerCalls) + } + if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" { + t.Fatalf("attempt tokens = %v", attemptTokens) + } +} + +// TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors asserts a failing +// refresh neither retries nor drops the refresh error or the original 401. +func TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors(t *testing.T) { + refreshErr := errors.New("refresh_token exchange failed") + attempts := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + return "", refreshErr + }, + SourceID: "s", + HTTPClient: client, + }) + if !errors.Is(err, refreshErr) { + t.Fatalf("error should wrap refresh error, got %v", err) + } + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("error should keep original 401, got %v", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1 (no retry after failed refresh)", attempts) + } +} + +// TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal covers backward +// compatibility: nil ForceRefreshToken keeps the single-attempt fatal 401. +func TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal(t *testing.T) { + attempts := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", AccessToken: "stale", SourceID: "s", HTTPClient: client, + }) + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("fatal 401 expected, got %v", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } +} + +// TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal guards against refresh loops: the +// controlled retry happens exactly once even if the rotated token is also +// rejected. +func TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal(t *testing.T) { + attempts := 0 + refreshCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + refreshCalls++ + return "rotated-but-still-rejected", nil + }, + SourceID: "s", + HTTPClient: client, + }) + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("fatal 401 expected after single retry, got %v", err) + } + if attempts != 2 || refreshCalls != 1 { + t.Fatalf("attempts = %d refreshCalls = %d, want 2/1", attempts, refreshCalls) + } +} + +// TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal asserts an empty rotated +// token is rejected instead of being sent to the server. +func TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal(t *testing.T) { + attempts := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + return " ", nil + }, + SourceID: "s", + HTTPClient: client, + }) + if err == nil || !strings.Contains(err.Error(), "empty token") { + t.Fatalf("empty rotated token error expected, got %v", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } +} + +// TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry mirrors the portal behavior for the +// personal stream ticket path. +func TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry(t *testing.T) { + var attemptTokens []string + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { return "stale", nil }, + ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) { + if rejectedToken != "stale" { + t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale") + } + return "rotated", nil + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + token := req.Header.Get("x-user-access-token") + attemptTokens = append(attemptTokens, token) + if token != "rotated" { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + ticket, err := src.fetchTicket(context.Background()) + if err != nil { + t.Fatalf("fetchTicket = %v", err) + } + if ticket.Endpoint != "wss://stream.test" || ticket.Ticket != "ticket" { + t.Fatalf("ticket = %#v", ticket) + } + if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" { + t.Fatalf("attempt tokens = %v", attemptTokens) + } +} + +// TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal asserts a failed refresh +// keeps the 401 fatal (not retryable) and wraps the refresh error. +func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(t *testing.T) { + refreshErr := errors.New("refresh_token exchange failed") + src, err := NewPersonal(PersonalConfig{ + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + return "", refreshErr + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + _, err = src.fetchTicket(context.Background()) + if !errors.Is(err, refreshErr) { + t.Fatalf("error should wrap refresh error, got %v", err) + } + if isRetryablePersonalError(err) { + t.Fatalf("failed refresh should stay fatal, got retryable %v", err) + } +} + +// brokenBody simulates a response body that fails mid-read, e.g. the server +// closing the connection before the error payload is fully written. +type brokenBody struct{} + +func (brokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (brokenBody) Close() error { return nil } + +func TestCrossPlatformCoveragePortalTicketNon2xxTruncatedBodyKeepsStatus(t *testing.T) { + for _, tc := range []struct { + status int + retryable bool + }{ + {status: http.StatusUnauthorized}, + {status: http.StatusTooManyRequests, retryable: true}, + {status: http.StatusServiceUnavailable, retryable: true}, + } { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: tc.status, Body: brokenBody{}, Header: make(http.Header)}, nil + })} + + _, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{ + TicketURL: "https://ticket.test", + SourceID: "source", + }, client, "token") + if status != tc.status || err == nil || !strings.Contains(err.Error(), fmt.Sprintf("HTTP %d", tc.status)) { + t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want HTTP %d", status, err, tc.status) + } + if errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("non-2xx status error should not expose diagnostic body read failure: %v", err) + } + var stageErr *portalStageError + if tc.retryable { + if !errors.As(err, &stageErr) || stageErr.status != tc.status || !stageErr.retryable { + t.Fatalf("retryable status should return retryable portalStageError, got %T %v", err, err) + } + } else if errors.As(err, &stageErr) && stageErr.retryable { + t.Fatalf("fatal status should not become retryable stage error: %v", err) + } + }) + } +} + +func TestCrossPlatformCoveragePortalTicket200TruncatedBodyIsRetryable(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: brokenBody{}, Header: make(http.Header)}, nil + })} + + _, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{ + TicketURL: "https://ticket.test", + SourceID: "source", + }, client, "token") + if status != http.StatusOK || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want 200 + io.ErrUnexpectedEOF", status, err) + } + var stageErr *portalStageError + if !errors.As(err, &stageErr) || !stageErr.retryable || stageErr.stage != "ticket_request" { + t.Fatalf("2xx truncated body should be retryable ticket_request stage, got %T %v", err, err) + } +} + +// TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal guards the single +// refresh-retry protection: a 401 whose body fails with unexpected EOF must +// be classified by status (fatal) and never wrapped as retryable, otherwise +// the outer reconnect loop would refresh again on every iteration. +func TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal(t *testing.T) { + attempts := 0 + refreshCalls := 0 + src, err := NewPersonal(PersonalConfig{ + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + refreshCalls++ + return "rotated", nil + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: brokenBody{}, Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + _, err = src.fetchTicket(context.Background()) + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("fatal 401 error expected, got %v", err) + } + if isRetryablePersonalError(err) { + t.Fatalf("401 with truncated body must stay fatal, got retryable %v", err) + } + if refreshCalls != 1 { + t.Fatalf("refresh calls = %d, want 1", refreshCalls) + } + if attempts != 2 { + t.Fatalf("attempts = %d, want 2", attempts) + } +} + +// TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable pins the existing +// behavior for success responses: a body read failure on 2xx is a transient +// transport problem and remains retryable. +func TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable(t *testing.T) { + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: brokenBody{}, Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + _, err = src.fetchTicket(context.Background()) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("error should wrap io.ErrUnexpectedEOF, got %v", err) + } + if !isRetryablePersonalError(err) { + t.Fatalf("2xx body read failure should stay retryable, got %v", err) + } +} + +// TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges covers the +// remaining fetchTicketAttempt branches: transport failures and retryable +// statuses stay retryable, while a well-formed response missing the endpoint +// or ticket fields stays fatal. +func TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges(t *testing.T) { + newSource := func(rt roundTripFunc) *PersonalSource { + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: rt}, + }) + if err != nil { + t.Fatal(err) + } + return src + } + + dialErr := errors.New("dial tcp: connection refused") + src := newSource(func(*http.Request) (*http.Response, error) { return nil, dialErr }) + _, _, err := src.fetchTicketAttempt(context.Background(), "token") + if !errors.Is(err, dialErr) || !isRetryablePersonalError(err) { + t.Fatalf("transport failure should stay retryable and wrap cause, got %v", err) + } + + src = newSource(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("busy")), Header: make(http.Header)}, nil + }) + _, status, err := src.fetchTicketAttempt(context.Background(), "token") + if status != http.StatusServiceUnavailable || !isRetryablePersonalError(err) { + t.Fatalf("503 should stay retryable, got status %d err %v", status, err) + } + + src = newSource(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"","ticket":""}`)), Header: make(http.Header)}, nil + }) + _, _, err = src.fetchTicketAttempt(context.Background(), "token") + if err == nil || isRetryablePersonalError(err) || !strings.Contains(err.Error(), "missing endpoint or ticket") { + t.Fatalf("missing ticket fields should stay fatal, got %v", err) + } +} diff --git a/internal/event/source/token_provider_test.go b/internal/event/source/token_provider_test.go new file mode 100644 index 00000000..174b2416 --- /dev/null +++ b/internal/event/source/token_provider_test.go @@ -0,0 +1,66 @@ +package source + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type tokenProviderRoundTripper func(*http.Request) (*http.Response, error) + +func (f tokenProviderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestCrossPlatformCoveragePersonalSourceResolvesTokenForEveryTicketRequest(t *testing.T) { + tokens := []string{"token-a", "token-b"} + calls := 0 + source, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { + token := tokens[calls] + calls++ + return token, nil + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(req *http.Request) (*http.Response, error) { + want := tokens[calls-1] + if got := req.Header.Get("x-user-access-token"); got != want { + t.Fatalf("token header = %q, want %q", got, want) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + for range 2 { + if _, err := source.fetchTicket(context.Background()); err != nil { + t.Fatal(err) + } + } + if calls != 2 { + t.Fatalf("provider calls = %d, want 2", calls) + } +} + +func TestCrossPlatformCoveragePortalTicketProviderFailureStopsBeforeHTTP(t *testing.T) { + want := errors.New("token store failed") + httpCalled := false + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://ticket.test", + AccessTokenProvider: func(context.Context) (string, error) { return "", want }, + SourceID: "source", + HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(*http.Request) (*http.Response, error) { + httpCalled = true + return nil, errors.New("unexpected HTTP") + })}, + }) + if !errors.Is(err, want) || httpCalled { + t.Fatalf("request error = %v, httpCalled=%v", err, httpCalled) + } +} diff --git a/internal/event/source/transient_auth_reconnect_test.go b/internal/event/source/transient_auth_reconnect_test.go new file mode 100644 index 00000000..35d3b395 --- /dev/null +++ b/internal/event/source/transient_auth_reconnect_test.go @@ -0,0 +1,295 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package source + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/gorilla/websocket" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +func transientRefreshError() error { + return &authpkg.HTTPStatusError{StatusCode: http.StatusServiceUnavailable} +} + +func terminalRefreshError() error { + return &authpkg.HTTPStatusError{StatusCode: http.StatusUnauthorized} +} + +func TestCrossPlatformCoveragePersonalRetryLogErrorReportsOnlySafeTransientStatus(t *testing.T) { + cause := errors.New("personal source: ticket HTTP 401 secret detail") + _, err := refreshRejectedSourceToken(context.Background(), func(context.Context, string) (string, error) { + return "", transientRefreshError() + }, "rejected", "personal source", cause) + if got, want := personalRetryLogError(retryPersonal(err)), "personal source: token refresh HTTP 503"; got != want { + t.Fatalf("personalRetryLogError() = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoveragePersonalSourceRetriesTransientTokenResolutionFailure(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var calls atomic.Int32 + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { + if calls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + ClientID: "client", + SourceID: "open", + TicketURL: "https://ticket.invalid", + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if calls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePersonalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) { + var calls atomic.Int32 + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { + calls.Add(1) + return "", terminalRefreshError() + }, + ClientID: "client", + SourceID: "open", + TicketURL: "https://ticket.invalid", + }) + if err != nil { + t.Fatal(err) + } + err = src.Start(context.Background(), func(*dwsevent.RawEvent) {}) + if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal { + t.Fatalf("Start() error = %v, want terminal refresh failure", err) + } + if calls.Load() != 1 || src.State().ReconnectCount != 0 { + t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePersonalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var ticketCalls atomic.Int32 + var refreshCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ticketCalls.Add(1) + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil }, + ForceRefreshToken: func(_ context.Context, rejected string) (string, error) { + if rejected != "old-token" { + t.Fatalf("rejected token = %q, want old-token", rejected) + } + if refreshCalls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + ClientID: "client", + SourceID: "open", + TicketURL: srv.URL, + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePersonalRetryLogErrorFallsBackToNetworkMessage(t *testing.T) { + err := retryPersonal(fmt.Errorf("personal source: resolve access token: %w", errors.New("dial tcp: lookup oauth.invalid"))) + if got, want := personalRetryLogError(err), "personal source: token refresh: temporary network error"; got != want { + t.Fatalf("personalRetryLogError() = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoveragePortalStageErrorNilAndUnwrap(t *testing.T) { + var nilErr *portalStageError + if got, want := nilErr.Error(), "source: portal stream failed"; got != want { + t.Fatalf("nil stage error = %q, want %q", got, want) + } + if nilErr.Unwrap() != nil { + t.Fatal("nil stage error should unwrap to nil") + } + cause := errors.New("cause") + stageErr := &portalStageError{stage: "stream_read", retryable: true, cause: cause} + if !errors.Is(stageErr, cause) { + t.Fatalf("stage error should unwrap to cause: %v", stageErr) + } +} + +func TestCrossPlatformCoveragePortalSourceRetriesTransientTokenResolutionFailure(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var calls atomic.Int32 + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: "https://ticket.invalid", + AccessTokenProvider: func(context.Context) (string, error) { + if calls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + SourceID: "open", + // Min above max exercises the reconnect clamp. + ReconnectMin: 2 * time.Millisecond, + ReconnectMax: time.Millisecond, + }}) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if calls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePortalSourceResetsBackoffAfterAckedAttempt(t *testing.T) { + var ticketCalls atomic.Int32 + upgrader := websocket.Upgrader{} + var wsURL string + mux := http.NewServeMux() + mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) { + if ticketCalls.Add(1) > 1 { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = io.WriteString(w, `{"endpoint":`+strconvQuote(wsURL)+`,"ticket":"t"}`) + }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "m"}, Data: `{}`} + _ = conn.WriteJSON(df) + // Wait for the ACK, then close so the read fails retryably with an + // acked attempt behind it, which resets the reconnect backoff. + _, _, _ = conn.ReadMessage() + }) + srv := httptest.NewServer(mux) + defer srv.Close() + wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: srv.URL + "/ticket", + AccessToken: "t", + SourceID: "open", + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }}) + if err != nil { + t.Fatal(err) + } + var events atomic.Int32 + err = src.Start(context.Background(), func(*dwsevent.RawEvent) { events.Add(1) }) + if err == nil || !strings.Contains(err.Error(), "portal ticket HTTP 400") { + t.Fatalf("Start() error = %v, want fatal ticket HTTP 400 after reconnect", err) + } + if events.Load() != 1 || ticketCalls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("events=%d ticket calls=%d reconnects=%d, want 1/2/1", events.Load(), ticketCalls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePortalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) { + var calls atomic.Int32 + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: "https://ticket.invalid", + AccessTokenProvider: func(context.Context) (string, error) { + calls.Add(1) + return "", terminalRefreshError() + }, + SourceID: "open", + }}) + if err != nil { + t.Fatal(err) + } + err = src.Start(context.Background(), func(*dwsevent.RawEvent) {}) + if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal { + t.Fatalf("Start() error = %v, want terminal refresh failure", err) + } + if calls.Load() != 1 || src.State().ReconnectCount != 0 { + t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePortalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var ticketCalls atomic.Int32 + var refreshCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ticketCalls.Add(1) + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: srv.URL, + AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil }, + ForceRefreshToken: func(_ context.Context, rejected string) (string, error) { + if rejected != "old-token" { + t.Fatalf("rejected token = %q, want old-token", rejected) + } + if refreshCalls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + SourceID: "open", + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }}) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount) + } +} diff --git a/internal/executor/invocation.go b/internal/executor/invocation.go index 62c537c0..6c29ab92 100644 --- a/internal/executor/invocation.go +++ b/internal/executor/invocation.go @@ -116,11 +116,14 @@ func NewWorkflowInvocation(legacyPath, workflowName string, steps []Invocation) func MergePayloads(jsonPayload, paramsPayload string, overrides map[string]any) (map[string]any, error) { merged := make(map[string]any) - for label, payload := range map[string]string{ - "--json": jsonPayload, - "--params": paramsPayload, + for _, payload := range []struct { + label string + raw string + }{ + {label: "--json", raw: jsonPayload}, + {label: "--params", raw: paramsPayload}, } { - value, err := parseJSONObject(label, payload) + value, err := parseJSONObject(payload.label, payload.raw) if err != nil { return nil, err } diff --git a/internal/executor/invocation_test.go b/internal/executor/invocation_test.go index b90b0781..4318db49 100644 --- a/internal/executor/invocation_test.go +++ b/internal/executor/invocation_test.go @@ -52,6 +52,10 @@ func TestCrossPlatformCoverageMergePayloadsAndToolCallRequest(t *testing.T) { if empty, err := MergePayloads(" ", "", nil); err != nil || len(empty) != 0 { t.Fatalf("empty MergePayloads() = %#v, %v", empty, err) } + merged, err = MergePayloads(`{"same":"json"}`, `{"same":"params"}`, nil) + if err != nil || merged["same"] != "params" { + t.Fatalf("MergePayloads() precedence = %#v, %v; want --params to win", merged, err) + } for _, input := range []string{`{`, `[]`, `null`} { if _, err := MergePayloads(input, "", nil); err == nil { t.Errorf("MergePayloads(%q) error = nil", input) diff --git a/internal/generator/agentmetadata/production_pipeline_external_test.go b/internal/generator/agentmetadata/production_pipeline_external_test.go index b2824d69..ac0c701f 100644 --- a/internal/generator/agentmetadata/production_pipeline_external_test.go +++ b/internal/generator/agentmetadata/production_pipeline_external_test.go @@ -18,7 +18,7 @@ func TestCrossPlatformCoverageGenerateProductionAgentMetadataPipeline(t *testing if err != nil { t.Fatal(err) } - root := app.NewRootCommand() + root := app.NewSchemaSourceRootCommand() if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil { t.Fatal(err) } diff --git a/internal/generator/cmd_schema_agent_metadata/main.go b/internal/generator/cmd_schema_agent_metadata/main.go index c4c4cf66..b4da5eaa 100644 --- a/internal/generator/cmd_schema_agent_metadata/main.go +++ b/internal/generator/cmd_schema_agent_metadata/main.go @@ -46,7 +46,7 @@ var ( writeMetadataFileBytes = os.WriteFile writeMetadataJSON = writeJSON - newMetadataRoot = app.NewRootCommand + newMetadataRoot = app.NewSchemaSourceRootCommand buildEffectiveMetadata = cli.BuildEffectiveCommandRegistry bindEffectiveMetadata = cli.BindEffectiveCommandRegistry loadSelectionMetadataHints = cli.LoadAgentHintsFromSelectionForValidation diff --git a/internal/generator/cmd_schema_catalog/main.go b/internal/generator/cmd_schema_catalog/main.go index 8be47812..3009a5be 100644 --- a/internal/generator/cmd_schema_catalog/main.go +++ b/internal/generator/cmd_schema_catalog/main.go @@ -27,6 +27,14 @@ import ( "github.com/spf13/cobra" ) +var ( + validateCatalogParameterBindings = cli.ValidateEmbeddedSchemaParameterBindings + buildCatalogSnapshot = cli.BuildSchemaCatalogSnapshot + makeCatalogDirectory = os.MkdirAll + writeCatalogFile = os.WriteFile + exitCatalogProcess = os.Exit +) + func main() { var rootPath string var surfacePath string @@ -40,7 +48,7 @@ func main() { fail(err) } - root := app.NewRootCommand() + root := app.NewSchemaSourceRootCommand() if err := generateSchemaCatalog(root, resolvedSurfacePath, outputPath); err != nil { fail(err) } @@ -103,7 +111,7 @@ func generateSchemaCatalogWithResolver(root *cobra.Command, surfacePath, outputP if err := validateDeprecatedSurface(surfacePath); err != nil { return err } - if err := cli.ValidateEmbeddedSchemaParameterBindings(); err != nil { + if err := validateCatalogParameterBindings(); err != nil { return fmt.Errorf("validate reviewed parameter binding input: %w", err) } @@ -111,7 +119,7 @@ func generateSchemaCatalogWithResolver(root *cobra.Command, surfacePath, outputP if err != nil { return fmt.Errorf("resolve final Schema build: %w", err) } - snapshot, err := cli.BuildSchemaCatalogSnapshot(resolved, cli.SchemaCatalogBuildOptions{ + snapshot, err := buildCatalogSnapshot(resolved, cli.SchemaCatalogBuildOptions{ RegistryHash: resolved.RegistryHash(), }) if err != nil { @@ -153,7 +161,7 @@ func writeSchemaCatalogShards(snapshot cli.SchemaCatalogSnapshot, outputDir stri return fmt.Errorf("clear stale schema catalog output: %w", err) } toolsDir := filepath.Join(outputDir, "tools") - if err := os.MkdirAll(toolsDir, 0o755); err != nil { + if err := makeCatalogDirectory(toolsDir, 0o755); err != nil { return fmt.Errorf("create schema catalog tools directory: %w", err) } @@ -207,7 +215,7 @@ func writeSchemaCatalogJSON(path string, value any) error { if err != nil { return fmt.Errorf("encode %s: %w", filepath.Base(path), err) } - return os.WriteFile(path, append(encoded, '\n'), 0o644) + return writeCatalogFile(path, append(encoded, '\n'), 0o644) } func validateDeprecatedSurface(path string) error { @@ -226,5 +234,5 @@ func validateDeprecatedSurface(path string) error { func fail(err error) { _, _ = fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) + exitCatalogProcess(1) } diff --git a/internal/generator/cmd_schema_catalog/main_test.go b/internal/generator/cmd_schema_catalog/main_test.go index 3da2a052..2513465d 100644 --- a/internal/generator/cmd_schema_catalog/main_test.go +++ b/internal/generator/cmd_schema_catalog/main_test.go @@ -5,6 +5,8 @@ package main import ( "encoding/json" + "errors" + "flag" "os" "path/filepath" "strings" @@ -15,8 +17,121 @@ import ( "github.com/spf13/cobra" ) -func TestGenerateSchemaCatalogResolvesBuildExactlyOnce(t *testing.T) { - root := app.NewRootCommand() +func TestCrossPlatformCoverageMainGeneratesCatalogToTemporaryFile(t *testing.T) { + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + oldArgs, oldFlags := os.Args, flag.CommandLine + t.Cleanup(func() { + os.Args, flag.CommandLine = oldArgs, oldFlags + }) + flag.CommandLine = flag.NewFlagSet("schema-catalog-coverage", flag.ContinueOnError) + os.Args = []string{"cmd_schema_catalog", "-root", repositoryRoot, "-output", filepath.Join(t.TempDir(), "schema_catalog")} + main() +} + +func TestCrossPlatformCoverageCatalogMainReportsIsolationAndGenerationFailures(t *testing.T) { + originalArgs, originalFlags := os.Args, flag.CommandLine + originalValidate := validateCatalogParameterBindings + originalExit := exitCatalogProcess + t.Cleanup(func() { + os.Args, flag.CommandLine = originalArgs, originalFlags + validateCatalogParameterBindings = originalValidate + exitCatalogProcess = originalExit + }) + exitCatalogProcess = func(int) { panic("exit") } + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + + invoke := func(args ...string) { + flag.CommandLine = flag.NewFlagSet("catalog-errors", flag.ContinueOnError) + os.Args = append([]string{"cmd_schema_catalog"}, args...) + defer func() { + if recover() != "exit" { + t.Fatal("main did not exit") + } + }() + main() + } + invoke("-root", repositoryRoot, "-output", filepath.Join(repositoryRoot, "internal/cli/schema_command_registry.json")) + validateCatalogParameterBindings = func() error { return errors.New("bindings") } + invoke("-root", repositoryRoot, "-output", filepath.Join(t.TempDir(), "catalog.json")) +} + +func TestCrossPlatformCoverageGenerateSchemaCatalogFailureEdges(t *testing.T) { + originalValidate := validateCatalogParameterBindings + originalSnapshot := buildCatalogSnapshot + originalMkdir := makeCatalogDirectory + originalWrite := writeCatalogFile + t.Cleanup(func() { + validateCatalogParameterBindings = originalValidate + buildCatalogSnapshot = originalSnapshot + makeCatalogDirectory = originalMkdir + writeCatalogFile = originalWrite + }) + root := &cobra.Command{Use: "dws"} + resolver := func(*cobra.Command) (cli.ResolvedSchemaBuild, error) { return cli.ResolvedSchemaBuild{}, nil } + output := filepath.Join(t.TempDir(), "catalog.json") + + if err := generateSchemaCatalogWithResolver(nil, "", output, resolver); err == nil || !strings.Contains(err.Error(), "root is nil") { + t.Fatalf("nil root error = %v", err) + } + if err := generateSchemaCatalogWithResolver(root, "", output, nil); err == nil || !strings.Contains(err.Error(), "resolver is nil") { + t.Fatalf("nil resolver error = %v", err) + } + if err := generateSchemaCatalogWithResolver(root, filepath.Join(t.TempDir(), "missing.json"), output, resolver); err == nil || !strings.Contains(err.Error(), "read deprecated") { + t.Fatalf("surface read error = %v", err) + } + validateCatalogParameterBindings = func() error { return errors.New("bindings") } + if err := generateSchemaCatalogWithResolver(root, "", output, resolver); err == nil || !strings.Contains(err.Error(), "parameter binding") { + t.Fatalf("binding error = %v", err) + } + validateCatalogParameterBindings = func() error { return nil } + if err := generateSchemaCatalogWithResolver(root, "", output, func(*cobra.Command) (cli.ResolvedSchemaBuild, error) { + return cli.ResolvedSchemaBuild{}, errors.New("resolve") + }); err == nil || !strings.Contains(err.Error(), "resolve final") { + t.Fatalf("resolver error = %v", err) + } + buildCatalogSnapshot = func(cli.ResolvedSchemaBuild, cli.SchemaCatalogBuildOptions) (cli.SchemaCatalogSnapshot, error) { + return cli.SchemaCatalogSnapshot{}, errors.New("snapshot") + } + if err := generateSchemaCatalogWithResolver(root, "", output, resolver); err == nil || !strings.Contains(err.Error(), "snapshot") { + t.Fatalf("snapshot error = %v", err) + } + buildCatalogSnapshot = func(cli.ResolvedSchemaBuild, cli.SchemaCatalogBuildOptions) (cli.SchemaCatalogSnapshot, error) { + return cli.SchemaCatalogSnapshot{}, nil + } + makeCatalogDirectory = func(string, os.FileMode) error { return errors.New("mkdir") } + if err := generateSchemaCatalogWithResolver(root, "", output, resolver); err == nil || !strings.Contains(err.Error(), "create schema catalog") { + t.Fatalf("mkdir error = %v", err) + } + makeCatalogDirectory = func(string, os.FileMode) error { return nil } + writeCatalogFile = func(string, []byte, os.FileMode) error { return errors.New("write") } + if err := generateSchemaCatalogWithResolver(root, "", output, resolver); err == nil || !strings.Contains(err.Error(), "write schema catalog") { + t.Fatalf("write error = %v", err) + } + + if got := resolveCatalogRootPath("root", "relative.json"); got != filepath.Join("root", "relative.json") { + t.Fatalf("resolved path = %q", got) + } + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + surface := filepath.Join(t.TempDir(), "registry.json") + if err := os.WriteFile(surface, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if err := validateCatalogOutputIsolation(repositoryRoot, filepath.Join(t.TempDir(), "catalog.json"), surface); err != nil { + t.Fatalf("surface isolation input rejected safe output: %v", err) + } +} + +func TestCrossPlatformCoverageGenerateSchemaCatalogResolvesBuildExactlyOnce(t *testing.T) { + root := app.NewSchemaSourceRootCommand() resolveCalls := 0 resolvedRegistryHash := "" resolver := func(candidate *cobra.Command) (cli.ResolvedSchemaBuild, error) { diff --git a/internal/generator/cmd_schema_registry_smoke/main.go b/internal/generator/cmd_schema_registry_smoke/main.go index 676916db..90e2d2ce 100644 --- a/internal/generator/cmd_schema_registry_smoke/main.go +++ b/internal/generator/cmd_schema_registry_smoke/main.go @@ -17,7 +17,7 @@ import ( ) var ( - newSmokeRoot = app.NewRootCommand + newSmokeRoot = app.NewSchemaSourceRootCommand buildEffectiveSmokeRegistry = cli.BuildEffectiveCommandRegistry bindEffectiveSmokeRegistry = cli.BindEffectiveCommandRegistry buildSmokeRegistryData = buildSmokeRegistry diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 49f5588b..887e1917 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -1433,19 +1433,25 @@ func newChatCommand() *cobra.Command { 纯文本 / Markdown 消息(默认): 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。 -富媒体消息(通过 --msg-type 指定类型): - image — 发送图片:--msg-type image --media-id(通过 dt_media_upload 上传获得) - file/audio/video — 发送文件、音频、视频:传本地 --file-path,CLI 会上传后按 file 消息发送`, +本地图片 / 文件消息: + 统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送; + .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。 + +旧版内联图片消息: + 仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。 + 当前 CLI 不提供本地文件到 mediaId 的上传能力。`, Example: ` dws chat message send --group "hello" dws chat message send --user "请查收" dws chat message send --open-dingtalk-id "请查收" dws chat message send --group --title "周报提醒" "请大家本周五前提交周报" - # 发送图片 - dws chat message send --group --msg-type image --media-id - # 发送本地文件/音频/视频(audio/video 是 file 的语义别名) + # 发送本地图片或文件(图片会作为可下载的 file 附件发送) + dws chat message send --group --msg-type file --file-path ./screenshot.png dws chat message send --group --msg-type file --file-path ./report.pdf + # 发送本地音频/视频(audio/video 是 file 的语义别名) dws chat message send --group --msg-type audio --file-path ./recording.mp3 dws chat message send --group --msg-type video --file-path ./demo.mp4 + # 旧版内联图片:仅当上游已经持有有效 mediaId 时使用 + dws chat message send --group --msg-type image --media-id # 查询群 ID: dws chat search --query "群名" # 查询用户 ID: dws contact user search --query "姓名"`, Args: cobra.MaximumNArgs(1), @@ -2400,13 +2406,13 @@ func newChatCommand() *cobra.Command { _ = chatMessageSendCmd.Flags().MarkHidden("markdown") chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>") chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>") - chatMessageSendCmd.Flags().String("media-id", "", "图片 mediaId(通过 dt_media_upload 上传后用 extract_media_id.py 提取,仅 msgType=image)") - chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video(audio/video 是 file 别名;纯文本/Markdown 无需指定,直接传内容即可)") + chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)") + chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)") chatMessageSendCmd.Flags().Int64("dentry-id", 0, "文件 dentryId(与 --space-id 成对传入时跳过自动上传)") chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)") chatMessageSendCmd.Flags().String("file-name", "", "文件名") chatMessageSendCmd.Flags().String("file-type", "", "文件类型/扩展名") - chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时可直接上传发送)") + chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)") chatMessageSendCmd.Flags().Int64("file-size", 0, "文件大小,单位字节") _ = chatMessageSendCmd.Flags().MarkHidden("dentry-id") _ = chatMessageSendCmd.Flags().MarkHidden("space-id") @@ -3143,6 +3149,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 # resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId # message-id: 从 dws chat message list 返回的 openMessageId # open-conversation-id: 从 dws chat search 获取 openConversationId`, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Cobra validates required flags after PreRunE. Copy a supplied alias + // into the canonical flag first so --message-id can remain a hard + // required fact in both the executable and Agent Schema contracts. + if cmd.Flags().Changed("message-id") { + return nil + } + alias := "" + switch { + case cmd.Flags().Changed("msg-id"): + alias = "msg-id" + case cmd.Flags().Changed("open-message-id"): + alias = "open-message-id" + default: + return nil + } + value, _ := cmd.Flags().GetString(alias) // registered string flags above + return cmd.Flags().Set("message-id", value) + }, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "type", "resource-id", "message-id", "open-conversation-id", "output"); err != nil { return err @@ -3226,6 +3251,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 _ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id") chatMessageDownloadMediaCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("message-id") + // Hidden aliases: agents routinely pass --msg-id / --open-message-id since + // the message-list output exposes the field as openMessageId/msgId. Accept + // them transparently instead of failing with "unknown flag". + chatMessageDownloadMediaCmd.Flags().String("msg-id", "", "--message-id 的别名") + _ = chatMessageDownloadMediaCmd.Flags().MarkHidden("msg-id") + chatMessageDownloadMediaCmd.Flags().String("open-message-id", "", "--message-id 的别名") + _ = chatMessageDownloadMediaCmd.Flags().MarkHidden("open-message-id") chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("output") @@ -3361,7 +3393,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 } iconMediaID := strings.TrimSpace(mustGetFlag(cmd, "icon-media-id")) if iconMediaID == "" { - return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 先通过媒体上传命令(dt_media_upload)上传图片,使用返回的 mediaId") + return fmt.Errorf("invalid --icon-media-id: mediaId 不能为空\n hint: 请使用上游媒体上传能力返回的有效 mediaId;DWS CLI 不提供本地文件到 mediaId 的上传命令") } return callMCPToolOnServer("im", "update_group_icon", map[string]any{ "openConversationId": mustGetFlag(cmd, "group"), diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index ba10d2f7..53ad7d8a 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -222,6 +222,12 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te tmp := t.TempDir() base := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--message-id=mid"} _ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, append(base, "--output="+filepath.Join(tmp, "dry"))...) + for _, alias := range []string{"--msg-id=mid", "--open-message-id=mid"} { + aliasArgs := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", alias, "--output=" + filepath.Join(tmp, "alias-dry")} + _ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, aliasArgs...) + } + missingMessageID := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--output=" + filepath.Join(tmp, "missing-id")} + _ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, missingMessageID...) for _, tc := range []struct { step scriptedToolStep out string diff --git a/internal/helpers/chat_file_path_send_test.go b/internal/helpers/chat_file_path_send_test.go new file mode 100644 index 00000000..005940e7 --- /dev/null +++ b/internal/helpers/chat_file_path_send_test.go @@ -0,0 +1,262 @@ +package helpers + +import ( + "context" + "crypto/md5" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type chatFilePathCall struct { + server string + tool string + args map[string]any +} + +type chatFilePathCaller struct { + sequence []string + calls []chatFilePathCall +} + +func (c *chatFilePathCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + c.sequence = append(c.sequence, tool) + copied := make(map[string]any, len(args)) + for key, value := range args { + copied[key] = value + } + c.calls = append(c.calls, chatFilePathCall{server: server, tool: tool, args: copied}) + + switch tool { + case "init_conversation_file_upload": + return textToolResult(`{"resourceUrl":"https://upload.example/file","uploadKey":"upload-key","headers":{"x-upload":"yes"}}`), nil + case "commit_conversation_file_upload": + return textToolResult(`{"result":{"dentryId":123,"spaceId":456}}`), nil + case "send_personal_message": + return textToolResult(`{"success":true}`), nil + default: + return nil, fmt.Errorf("unexpected tool call %s/%s", server, tool) + } +} + +func (*chatFilePathCaller) Format() string { return "json" } +func (*chatFilePathCaller) DryRun() bool { return false } +func (*chatFilePathCaller) Fields() string { return "" } +func (*chatFilePathCaller) JQ() string { return "" } + +func TestChatMessageSendFilePathUsesWukongUploadSequence(t *testing.T) { + previousDeps, previousPut, previousArgs := deps, httpPutFile, os.Args + t.Cleanup(func() { + deps = previousDeps + httpPutFile = previousPut + os.Args = previousArgs + }) + + filePath := filepath.Join(t.TempDir(), "image.png") + payload := []byte("png payload") + if err := os.WriteFile(filePath, payload, 0o600); err != nil { + t.Fatal(err) + } + + caller := &chatFilePathCaller{} + commandArgs := []string{ + "message", "send", + "--group=cid", + "--msg-type=file", + "--file-path=" + filePath, + } + os.Args = append([]string{"dws", "chat"}, commandArgs...) + httpPutFile = func(_ context.Context, resourceURL string, headers map[string]string, localPath string, fileSize int64) error { + caller.sequence = append(caller.sequence, "HTTP PUT") + if resourceURL != "https://upload.example/file" { + t.Fatalf("resourceURL = %q", resourceURL) + } + if headers["x-upload"] != "yes" { + t.Fatalf("headers = %#v", headers) + } + if localPath != filePath || fileSize != int64(len(payload)) { + t.Fatalf("upload file = %q (%d), want %q (%d)", localPath, fileSize, filePath, len(payload)) + } + return nil + } + + err := runChatCoverageCommand(t, caller, commandArgs...) + if err != nil { + t.Fatalf("chat message send --file-path: %v", err) + } + + wantSequence := []string{ + "init_conversation_file_upload", + "HTTP PUT", + "commit_conversation_file_upload", + "send_personal_message", + } + if !reflect.DeepEqual(caller.sequence, wantSequence) { + t.Fatalf("call sequence = %#v, want %#v", caller.sequence, wantSequence) + } + if len(caller.calls) != 3 { + t.Fatalf("tool calls = %#v, want init, commit, send", caller.calls) + } + + fileMD5 := fmt.Sprintf("%x", md5.Sum(payload)) + wantInit := chatFilePathCall{ + server: "im", + tool: "init_conversation_file_upload", + args: map[string]any{ + "openConversationId": "cid", + "fileName": "image.png", + "fileSize": int64(len(payload)), + "md5": fileMD5, + }, + } + if !reflect.DeepEqual(caller.calls[0], wantInit) { + t.Fatalf("init call = %#v, want %#v", caller.calls[0], wantInit) + } + wantCommit := chatFilePathCall{ + server: "im", + tool: "commit_conversation_file_upload", + args: map[string]any{ + "openConversationId": "cid", + "uploadKey": "upload-key", + "fileName": "image.png", + "fileSize": int64(len(payload)), + "md5": fileMD5, + }, + } + if !reflect.DeepEqual(caller.calls[1], wantCommit) { + t.Fatalf("commit call = %#v, want %#v", caller.calls[1], wantCommit) + } + + send := caller.calls[len(caller.calls)-1] + if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" { + t.Fatalf("send call = %#v", send) + } + if send.args["openConversationId"] != "cid" { + t.Fatalf("send target = %#v", send.args["openConversationId"]) + } + content, ok := send.args["content"].(string) + if !ok { + t.Fatalf("send content = %#v", send.args["content"]) + } + var parsed struct { + DentryID int64 `json:"dentryId"` + SpaceID int64 `json:"spaceId"` + FileName string `json:"fileName"` + FileType string `json:"fileType"` + FilePath string `json:"filePath"` + FileSize int64 `json:"fileSize"` + } + if err := json.Unmarshal([]byte(content), &parsed); err != nil { + t.Fatalf("decode send content %q: %v", content, err) + } + if parsed.DentryID != 123 || parsed.SpaceID != 456 || + parsed.FileName != "image.png" || parsed.FileType != "png" || + parsed.FilePath != "/image.png" || parsed.FileSize != int64(len(payload)) { + t.Fatalf("send content = %#v", parsed) + } +} + +func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) { + previousDeps, previousPut, previousArgs := deps, httpPutFile, os.Args + t.Cleanup(func() { + deps = previousDeps + httpPutFile = previousPut + os.Args = previousArgs + }) + + filePath := filepath.Join(t.TempDir(), "report.pdf") + payload := []byte("pdf payload") + if err := os.WriteFile(filePath, payload, 0o600); err != nil { + t.Fatal(err) + } + + caller := &chatFilePathCaller{} + commandArgs := []string{ + "message", "send", + "--open-dingtalk-id=D-target", + "--msg-type=file", + "--file-path=" + filePath, + } + os.Args = append([]string{"dws", "chat"}, commandArgs...) + httpPutFile = func(_ context.Context, resourceURL string, _ map[string]string, localPath string, fileSize int64) error { + caller.sequence = append(caller.sequence, "HTTP PUT") + if resourceURL != "https://upload.example/file" || localPath != filePath || fileSize != int64(len(payload)) { + t.Fatalf("upload = %q, %q (%d)", resourceURL, localPath, fileSize) + } + return nil + } + + if err := runChatCoverageCommand(t, caller, commandArgs...); err != nil { + t.Fatalf("chat message send --open-dingtalk-id --file-path: %v", err) + } + if len(caller.calls) != 3 { + t.Fatalf("tool calls = %#v, want init, commit, send", caller.calls) + } + + fileMD5 := fmt.Sprintf("%x", md5.Sum(payload)) + wantInit := chatFilePathCall{ + server: "im", + tool: "init_conversation_file_upload", + args: map[string]any{ + "openDingTalkId": "D-target", + "fileName": "report.pdf", + "fileSize": int64(len(payload)), + "md5": fileMD5, + }, + } + if !reflect.DeepEqual(caller.calls[0], wantInit) { + t.Fatalf("init direct target call = %#v, want %#v", caller.calls[0], wantInit) + } + wantCommit := chatFilePathCall{ + server: "im", + tool: "commit_conversation_file_upload", + args: map[string]any{ + "openDingTalkId": "D-target", + "uploadKey": "upload-key", + "fileName": "report.pdf", + "fileSize": int64(len(payload)), + "md5": fileMD5, + }, + } + if !reflect.DeepEqual(caller.calls[1], wantCommit) { + t.Fatalf("commit direct target call = %#v, want %#v", caller.calls[1], wantCommit) + } + + send := caller.calls[2] + if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" { + t.Fatalf("send direct target call = %#v", send) + } + if send.args["receiverOpenDingTalkId"] != "D-target" { + t.Fatalf("send direct target = %#v", send.args) + } + if _, ok := send.args["openDingTalkId"]; ok { + t.Fatalf("send direct target leaked upload target key: %#v", send.args) + } +} + +func TestChatMessageSendFilePathRequiresFileMessageType(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + filePath := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(filePath, []byte("png"), 0o600); err != nil { + t.Fatal(err) + } + caller := &chatFilePathCaller{} + err := runChatCoverageCommand(t, caller, + "message", "send", + "--group=cid", + "--file-path="+filePath, + ) + if err == nil { + t.Fatal("bare --file-path succeeded without --msg-type=file") + } + if len(caller.calls) != 0 { + t.Fatalf("bare --file-path made remote calls: %#v", caller.calls) + } +} diff --git a/internal/helpers/chat_media_upload.go b/internal/helpers/chat_media_upload.go index d616a75a..80e26cc9 100644 --- a/internal/helpers/chat_media_upload.go +++ b/internal/helpers/chat_media_upload.go @@ -14,38 +14,23 @@ package helpers import ( - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) -var ( - chatMediaResolveAppToken = mediaResolveAppToken - chatMediaUploadFile = mediaUploadFile - mediaCreateFormFile = func(w *multipart.Writer, field, name string) (io.Writer, error) { return w.CreateFormFile(field, name) } - mediaOpenFile = os.Open - mediaCopyFile = io.Copy -) +const chatMediaUploadReplacement = "dws chat message send --msg-type file --file-path <本地路径>" func newChatMediaGroup() *cobra.Command { media := &cobra.Command{ Use: "media", - Short: "媒体文件管理", + Short: "已下线:媒体文件上传兼容入口", + Deprecated: "本地图片和文件请改用 " + chatMediaUploadReplacement, Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, + RunE: func(*cobra.Command, []string) error { + return chatMediaUploadDownlineError() + }, } media.AddCommand(newChatMediaUploadCommand()) return media @@ -53,160 +38,33 @@ func newChatMediaGroup() *cobra.Command { func newChatMediaUploadCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "upload", - Short: "上传图片获取 mediaId(用于 chat message send --msg-type image)", - Example: " dws chat media upload --file ./screenshot.png\n" + - " dws chat media upload --file ./photo.jpg --type image", + Use: "upload", + Short: "已下线:请通过 chat message send 直接发送本地文件", + Deprecated: "请改用 " + chatMediaUploadReplacement, + Long: `此命令仅为 1.x 命令行兼容保留,不再读取应用凭证或调用旧版媒体上传接口。 + +发送本地图片或文件时,请使用 chat message send --msg-type file --file-path。 +该路径会把图片作为可下载的 file 消息发送,不会生成 mediaId,也不会渲染成内联 image 消息。 +如果上游已经提供 mediaId,仍可使用 chat message send --msg-type image --media-id。`, + Example: " dws chat message send --group --msg-type file --file-path ./screenshot.png\n" + + " dws chat message send --open-dingtalk-id --msg-type file --file-path ./report.pdf", Args: cobra.NoArgs, DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { - filePath, _ := cmd.Flags().GetString("file") - filePath = strings.TrimSpace(filePath) - if filePath == "" { - return apperrors.NewValidation("--file is required") - } - fi, err := os.Stat(filePath) - if err != nil { - return apperrors.NewValidation("cannot read file: " + err.Error()) - } - if fi.IsDir() { - return apperrors.NewValidation(filePath + " is a directory") - } - mediaType, _ := cmd.Flags().GetString("type") - mediaType = strings.TrimSpace(strings.ToLower(mediaType)) - if mediaType == "" { - mediaType = "image" - } - - ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute) - defer cancel() - - token, err := chatMediaResolveAppToken(ctx) - if err != nil { - return err - } - - mediaID, err := chatMediaUploadFile(ctx, token, filePath, mediaType) - if err != nil { - return err - } - - return writeCommandPayload(cmd, map[string]any{ - "success": true, - "mediaId": mediaID, - }) + RunE: func(*cobra.Command, []string) error { + return chatMediaUploadDownlineError() }, } - cmd.Flags().String("file", "", "本地文件路径 (必填)") - cmd.Flags().String("type", "image", "媒体类型: image/voice/video/file") + // Keep the historical flags so existing argv remains parseable while the + // 1.x compatibility command returns an actionable migration error. + cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file-path") + cmd.Flags().String("type", "image", "旧版兼容参数;不再执行媒体上传") return cmd } -func mediaResolveAppToken(ctx context.Context) (string, error) { - return mediaResolveAppTokenWithRequest(ctx, http.NewRequestWithContext) -} - -type mediaRequestFactory func(context.Context, string, string, io.Reader) (*http.Request, error) - -func mediaResolveAppTokenWithRequest(ctx context.Context, newRequest mediaRequestFactory) (string, error) { - appKey := os.Getenv("DWS_CLIENT_ID") - appSecret := os.Getenv("DWS_CLIENT_SECRET") - if appKey == "" || appSecret == "" { - return "", apperrors.NewAuth( - "缺少应用凭证。chat media upload 需要 DWS_CLIENT_ID / DWS_CLIENT_SECRET 环境变量。\n" + - "请使用 dws auth login --client-id --client-secret 登录。") - } - endpoint := url.URL{Scheme: "https", Host: "oapi.dingtalk.com", Path: "/gettoken"} - endpoint.RawQuery = url.Values{ - "appkey": []string{appKey}, - "appsecret": []string{appSecret}, - }.Encode() - req, err := newRequest(ctx, http.MethodGet, endpoint.String(), nil) - if err != nil { - return "", apperrors.NewAuth("构造访问令牌请求失败: " + err.Error()) - } - resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) - if err != nil { - return "", apperrors.NewAuth("获取访问令牌失败: " + err.Error()) - } - defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode >= 400 { - return "", apperrors.NewAuth(fmt.Sprintf("获取访问令牌 HTTP %d: %s", resp.StatusCode, string(raw))) - } - var parsed struct { - AccessToken string `json:"access_token"` - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - } - if err := json.Unmarshal(raw, &parsed); err != nil { - return "", apperrors.NewAuth("gettoken 响应解析失败: " + string(raw)) - } - if parsed.ErrCode != 0 || parsed.AccessToken == "" { - return "", apperrors.NewAuth(fmt.Sprintf("gettoken errcode=%d errmsg=%s", parsed.ErrCode, parsed.ErrMsg)) - } - return parsed.AccessToken, nil -} - -func mediaUploadFile(ctx context.Context, token, filePath, mediaType string) (string, error) { - return mediaUploadFileWithRequest(ctx, token, filePath, mediaType, http.NewRequestWithContext) -} - -func mediaUploadFileWithRequest(ctx context.Context, token, filePath, mediaType string, newRequest mediaRequestFactory) (string, error) { - pr, pw := io.Pipe() - writer := multipart.NewWriter(pw) - endpoint := url.URL{Scheme: "https", Host: "oapi.dingtalk.com", Path: "/media/upload"} - endpoint.RawQuery = url.Values{ - "access_token": []string{token}, - "type": []string{mediaType}, - }.Encode() - req, err := newRequest(ctx, http.MethodPost, endpoint.String(), pr) - if err != nil { - _ = pr.CloseWithError(err) - _ = pw.CloseWithError(err) - return "", apperrors.NewAPI("construct media upload request: " + err.Error()) - } - req.Header.Set("Content-Type", writer.FormDataContentType()) - - go func() { - defer pw.Close() - defer writer.Close() - part, err := mediaCreateFormFile(writer, "media", filepath.Base(filePath)) - if err != nil { - pw.CloseWithError(err) - return - } - f, err := mediaOpenFile(filePath) - if err != nil { - pw.CloseWithError(err) - return - } - defer f.Close() - if _, err := mediaCopyFile(part, f); err != nil { - pw.CloseWithError(err) - } - }() - - resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req) - if err != nil { - return "", apperrors.NewAPI("media upload failed: " + err.Error()) - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode >= 400 { - return "", apperrors.NewAPI(fmt.Sprintf("media upload HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))) - } - - var parsed struct { - MediaID string `json:"media_id"` - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - } - if err := json.Unmarshal(body, &parsed); err != nil { - return "", apperrors.NewAPI("media upload 响应解析失败: " + string(body)) - } - if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" { - return "", apperrors.NewAPI(fmt.Sprintf("media upload errcode=%d errmsg=%s body=%s", parsed.ErrCode, parsed.ErrMsg, string(body))) - } - return strings.TrimSpace(parsed.MediaID), nil +func chatMediaUploadDownlineError() error { + return apperrors.NewValidation( + "chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。" + + " 本地图片或文件请改用: " + chatMediaUploadReplacement + + ";已有 mediaId 时可使用 dws chat message send --msg-type image --media-id 。", + ) } diff --git a/internal/helpers/chat_media_upload_coverage_more_test.go b/internal/helpers/chat_media_upload_coverage_more_test.go index 22e92f98..298fcbc6 100644 --- a/internal/helpers/chat_media_upload_coverage_more_test.go +++ b/internal/helpers/chat_media_upload_coverage_more_test.go @@ -1,87 +1,36 @@ package helpers import ( - "bytes" - "context" - "errors" - "io" - "mime/multipart" - "net/http" - "os" - "path/filepath" "strings" "testing" ) -func TestCrossPlatformCoverageChatMediaUploadCommandRemainingCoverage(t *testing.T) { - file := filepath.Join(t.TempDir(), "image.png") - if err := os.WriteFile(file, []byte("image"), 0o600); err != nil { - t.Fatal(err) - } - if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", t.TempDir()); err == nil { - t.Fatal("directory upload returned nil") +func TestCrossPlatformCoverageChatMediaUploadIsDeprecatedCompatibilityStub(t *testing.T) { + group := newChatMediaGroup() + if group.Deprecated == "" || group.Hidden || !group.Runnable() { + t.Fatalf("media group compatibility contract: deprecated=%q hidden=%v runnable=%v", group.Deprecated, group.Hidden, group.Runnable()) } - origResolve, origUpload := chatMediaResolveAppToken, chatMediaUploadFile - t.Cleanup(func() { - chatMediaResolveAppToken = origResolve - chatMediaUploadFile = origUpload - }) - chatMediaResolveAppToken = func(context.Context) (string, error) { return "", errors.New("token") } - if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil { - t.Fatal("token failure returned nil") - } - chatMediaResolveAppToken = func(context.Context) (string, error) { return "token", nil } - chatMediaUploadFile = func(context.Context, string, string, string) (string, error) { return "", errors.New("upload") } - if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil { - t.Fatal("upload failure returned nil") - } - var mediaType string - chatMediaUploadFile = func(_ context.Context, _, _, typ string) (string, error) { - mediaType = typ - return "media-id", nil - } cmd := newChatMediaUploadCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(io.Discard) - cmd.SetArgs([]string{"--file", file, "--type", " "}) - if err := cmd.Execute(); err != nil || mediaType != "image" || !strings.Contains(out.String(), "media-id") { - t.Fatalf("success type=%q output=%q err=%v", mediaType, out.String(), err) + if cmd.Deprecated == "" || cmd.Hidden || !cmd.Runnable() { + t.Fatalf("media upload compatibility contract: deprecated=%q hidden=%v runnable=%v", cmd.Deprecated, cmd.Hidden, cmd.Runnable()) } -} - -func TestCrossPlatformCoverageMediaUploadMultipartWriterRemainingFailures(t *testing.T) { - file := filepath.Join(t.TempDir(), "image.png") - if err := os.WriteFile(file, []byte("image"), 0o600); err != nil { - t.Fatal(err) - } - origTransport := http.DefaultTransport - origCreate, origOpen, origCopy := mediaCreateFormFile, mediaOpenFile, mediaCopyFile - t.Cleanup(func() { - http.DefaultTransport = origTransport - mediaCreateFormFile, mediaOpenFile, mediaCopyFile = origCreate, origOpen, origCopy - }) - http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { - _, err := io.ReadAll(req.Body) - if err != nil { - return nil, err + for _, flag := range []string{"file", "type"} { + if cmd.Flags().Lookup(flag) == nil { + t.Fatalf("media upload lost historical --%s flag", flag) } - return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"media_id":"id"}`)), Request: req}, nil - }) + } - mediaCreateFormFile = func(*multipart.Writer, string, string) (io.Writer, error) { return nil, errors.New("part") } - if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil { - t.Fatal("form part failure returned nil") + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--file", "/path/that/does/not/exist.png", "--type", "image"}) + err := cmd.Execute() + if err == nil { + t.Fatal("deprecated media upload returned nil error") } - mediaCreateFormFile = origCreate - mediaOpenFile = func(string) (*os.File, error) { return nil, errors.New("open") } - if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil { - t.Fatal("open failure returned nil") - } - mediaOpenFile = origOpen - mediaCopyFile = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") } - if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil { - t.Fatal("copy failure returned nil") + for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file-path", "--media-id"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("media upload migration error missing %q: %v", want, err) + } } } diff --git a/internal/helpers/http_coverage_more_test.go b/internal/helpers/http_coverage_more_test.go index c6027c53..0ccef7b0 100644 --- a/internal/helpers/http_coverage_more_test.go +++ b/internal/helpers/http_coverage_more_test.go @@ -3,14 +3,11 @@ package helpers import ( "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" - "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -117,86 +114,7 @@ func TestCrossPlatformCoverageMailHTTPTransfersCoverage(t *testing.T) { } } -func TestCrossPlatformCoverageChatMediaHTTPAndDocVersionsCoverage(t *testing.T) { - previousTransport := http.DefaultTransport - mode := "success" - var requestQuery url.Values - http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { - if mode == "transport-error" { - return nil, errors.New("transport") - } - if req.Body != nil { - if _, err := io.ReadAll(req.Body); err != nil { - return nil, err - } - } - requestQuery = req.URL.Query() - status := http.StatusOK - body := `{"access_token":"token","errcode":0}` - if strings.Contains(req.URL.Path, "media/upload") { - body = `{"media_id":"media-id","errcode":0}` - } - switch mode { - case "http-error": - status = http.StatusInternalServerError - body = `failure` - case "invalid-json": - body = `{` - case "api-error": - body = `{"errcode":1,"errmsg":"failed"}` - } - return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil - }) - t.Cleanup(func() { http.DefaultTransport = previousTransport }) - - t.Setenv("DWS_CLIENT_ID", "") - t.Setenv("DWS_CLIENT_SECRET", "") - if _, err := mediaResolveAppToken(context.Background()); err == nil { - t.Fatal("missing media credentials succeeded") - } - t.Setenv("DWS_CLIENT_ID", "client") - t.Setenv("DWS_CLIENT_SECRET", "secret") - for _, current := range []string{"transport-error", "http-error", "invalid-json", "api-error", "success"} { - mode = current - _, _ = mediaResolveAppToken(context.Background()) - } - t.Setenv("DWS_CLIENT_ID", "client&scope=chat") - t.Setenv("DWS_CLIENT_SECRET", "secret=with?reserved") - mode = "success" - if _, err := mediaResolveAppToken(context.Background()); err != nil { - t.Fatal(err) - } - if requestQuery.Get("appkey") != "client&scope=chat" || requestQuery.Get("appsecret") != "secret=with?reserved" { - t.Fatalf("token query was not encoded: %v", requestQuery) - } - file := filepath.Join(t.TempDir(), "image.png") - if err := os.WriteFile(file, []byte("image"), 0o600); err != nil { - t.Fatal(err) - } - for _, current := range []string{"transport-error", "http-error", "invalid-json", "api-error", "success"} { - mode = current - _, _ = mediaUploadFile(context.Background(), "token", file, "image") - } - mode = "success" - if _, err := mediaUploadFile(context.Background(), "token&scope=chat", file, "image&type=file"); err != nil { - t.Fatal(err) - } - if requestQuery.Get("access_token") != "token&scope=chat" || requestQuery.Get("type") != "image&type=file" { - t.Fatalf("upload query was not encoded: %v", requestQuery) - } - mode = "success" - _, _ = mediaUploadFile(context.Background(), "token", filepath.Join(t.TempDir(), "missing"), "image") - - requestFailure := func(context.Context, string, string, io.Reader) (*http.Request, error) { - return nil, errors.New("request") - } - if _, err := mediaResolveAppTokenWithRequest(context.Background(), requestFailure); err == nil { - t.Fatal("token request construction failure returned nil") - } - if _, err := mediaUploadFileWithRequest(context.Background(), "token", file, "image", requestFailure); err == nil { - t.Fatal("upload request construction failure returned nil") - } - +func TestCrossPlatformCoverageDocVersionsCoverage(t *testing.T) { for _, value := range []any{float64(3), float64(3.5), "3", "bad", jsonNumber("3"), jsonNumber("bad"), true} { _ = docVersionNumberMatches(value, 3) } diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 7cbb7133..cfc2475a 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -36,6 +36,11 @@ const ( // parallel. When empty, the platform default applies. StorageDirEnv = "DWS_KEYCHAIN_DIR" + // TestNamespaceEnv isolates the Windows HKCU registry backend for tests. + // Production code must not set it. Other platforms already isolate secure + // storage through StorageDirEnv and ignore this value. + TestNamespaceEnv = "DWS_KEYCHAIN_TEST_NAMESPACE" + // DisableKeychainEnv opts the macOS implementation out of system // Keychain access for the DEK, falling back to a file-based DEK // (same scheme as Linux). Intended for sandboxed runtimes where diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go index 16ca3e12..527fb6bb 100644 --- a/internal/keychain/keychain_test.go +++ b/internal/keychain/keychain_test.go @@ -14,6 +14,7 @@ package keychain import ( + "fmt" "os" "path/filepath" "testing" @@ -25,8 +26,15 @@ func TestMain(m *testing.M) { panic(err) } _ = os.Setenv(StorageDirEnv, dir) + _ = os.Setenv(TestNamespaceEnv, dir) _ = os.Setenv(DisableKeychainEnv, "1") code := m.Run() + if err := RemoveAuthTokenEntries(Service); err != nil { + fmt.Fprintf(os.Stderr, "internal/keychain test cleanup: %v\n", err) + if code == 0 { + code = 1 + } + } _ = os.RemoveAll(dir) os.Exit(code) } diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go index 69e66af6..00c35767 100644 --- a/internal/keychain/keychain_windows.go +++ b/internal/keychain/keychain_windows.go @@ -16,6 +16,7 @@ package keychain import ( + "crypto/sha256" "encoding/base64" "errors" "fmt" @@ -47,9 +48,8 @@ const regRootPath = `Software\DwsCli\keychain` // The Windows keychain backend keeps secrets in DPAPI-protected HKCU registry // values rather than on disk, so this path is used only by the portable // auth-bundle export/import (internal/auth) to colocate config. When the -// DWS_KEYCHAIN_DIR environment variable is set (used by tests for isolation), -// the storage root is taken from that env var instead; otherwise it defaults -// to %LocalAppData%\. +// DWS_KEYCHAIN_DIR environment variable is set, the storage root is taken from +// that env var; otherwise it defaults to %LocalAppData%\. func StorageDir(service string) string { if override := os.Getenv(StorageDirEnv); override != "" { return filepath.Join(override, service) @@ -66,7 +66,22 @@ func StorageDir(service string) string { } func registryPathForService(service string) string { - return regRootPath + `\` + safeRegistryComponent(service) + path := regRootPath + `\` + safeRegistryComponent(service) + namespace := strings.TrimSpace(os.Getenv(TestNamespaceEnv)) + if namespace == "" { + return path + } + + // Windows stores credentials in HKCU instead of DWS_KEYCHAIN_DIR. Tests set + // an explicit process namespace so concurrent package binaries cannot + // delete each other's credentials. Hash it to avoid leaking temp paths or + // introducing registry separators. + namespace = filepath.Clean(namespace) + if absolute, err := filepath.Abs(namespace); err == nil { + namespace = absolute + } + sum := sha256.Sum256([]byte(strings.ToLower(namespace))) + return fmt.Sprintf(`%s\test-%x`, path, sum[:16]) } var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) diff --git a/internal/keychain/keychain_windows_test.go b/internal/keychain/keychain_windows_test.go index 9d9f4b80..73a5d91a 100644 --- a/internal/keychain/keychain_windows_test.go +++ b/internal/keychain/keychain_windows_test.go @@ -17,12 +17,37 @@ package keychain import ( "errors" + "strings" "testing" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) +func TestCrossPlatformCoverageRegistryPathForServiceHonorsTestNamespace(t *testing.T) { + t.Setenv(TestNamespaceEnv, "") + defaultPath := registryPathForService("service") + if defaultPath != regRootPath+`\service` { + t.Fatalf("default registry path = %q, want historical path %q", defaultPath, regRootPath+`\service`) + } + + t.Setenv(TestNamespaceEnv, t.TempDir()) + firstPath := registryPathForService("service") + + t.Setenv(TestNamespaceEnv, t.TempDir()) + secondPath := registryPathForService("service") + + if firstPath == defaultPath || secondPath == defaultPath { + t.Fatalf("isolated registry paths = %q, %q; want paths distinct from %q", firstPath, secondPath, defaultPath) + } + if firstPath == secondPath { + t.Fatalf("isolated registry paths collide: %q", firstPath) + } + if !strings.HasPrefix(firstPath, defaultPath+`\test-`) { + t.Fatalf("isolated registry path = %q, want prefix %q", firstPath, defaultPath+`\test-`) + } +} + func TestDeleteRegistryValuePropagatesFailure(t *testing.T) { originalDelete := registryDeleteValue failure := errors.New("delete failed") diff --git a/internal/plugin/converter.go b/internal/plugin/converter.go index 413d3a2e..289dba1d 100644 --- a/internal/plugin/converter.go +++ b/internal/plugin/converter.go @@ -14,7 +14,9 @@ package plugin import ( + "bytes" "encoding/json" + "io" "log/slog" "os" "path/filepath" @@ -24,6 +26,8 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" ) +const maxPluginCLIOverlayBytes = 4 << 20 + // UserContext holds the minimal user identity fields injected into // stdio plugin subprocesses via environment variables. type UserContext struct { @@ -111,23 +115,9 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor { continue } - overlay := mcptypes.CLIOverlay{} - if len(srv.CLI) > 0 { - if err := json.Unmarshal(srv.CLI, &overlay); err != nil { - slog.Warn("plugin: failed to parse CLIOverlay", - "plugin", p.Manifest.Name, - "server", key, - "error", err, - ) - } - } - - // Ensure the overlay has an ID — fall back to server key. - if overlay.ID == "" { - overlay.ID = key - } - if overlay.Command == "" { - overlay.Command = key + overlay, ok := p.ResolveCLIOverlay(key) + if !ok { + continue } source := "plugin" @@ -154,3 +144,73 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor { } return descriptors } + +// ResolveCLIOverlay resolves inline or external manifest CLI metadata exactly +// once. External files are opened relative to the plugin root with os.Root so +// absolute paths, parent traversal, and escaping symlinks fail closed. +func (p *Plugin) ResolveCLIOverlay(serverKey string) (mcptypes.CLIOverlay, bool) { + overlay := mcptypes.CLIOverlay{ + ID: serverKey, + Command: serverKey, + } + server, ok := p.Manifest.MCPServers[serverKey] + if !ok || len(server.CLI) == 0 { + return overlay, true + } + + data := []byte(strings.TrimSpace(string(server.CLI))) + if len(data) == 0 { + return overlay, true + } + if data[0] == '"' { + var relativePath string + if err := json.Unmarshal(data, &relativePath); err != nil || + strings.TrimSpace(relativePath) == "" { + slog.Warn("plugin: invalid external CLI overlay path", + "plugin", p.Manifest.Name, "server", serverKey, "error", err) + return mcptypes.CLIOverlay{}, false + } + root, err := os.OpenRoot(p.Root) + if err != nil { + slog.Warn("plugin: failed to open plugin root", + "plugin", p.Manifest.Name, "server", serverKey, "error", err) + return mcptypes.CLIOverlay{}, false + } + defer root.Close() + file, err := root.Open(relativePath) + if err != nil { + slog.Warn("plugin: failed to open CLI overlay file", + "plugin", p.Manifest.Name, "server", serverKey, + "path", relativePath, "error", err) + return mcptypes.CLIOverlay{}, false + } + defer file.Close() + data, err = io.ReadAll(io.LimitReader(file, maxPluginCLIOverlayBytes+1)) + if err != nil || len(data) > maxPluginCLIOverlayBytes { + slog.Warn("plugin: failed to read CLI overlay file", + "plugin", p.Manifest.Name, "server", serverKey, + "path", relativePath, "error", err) + return mcptypes.CLIOverlay{}, false + } + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&overlay); err != nil { + slog.Warn("plugin: failed to parse CLI overlay", + "plugin", p.Manifest.Name, "server", serverKey, "error", err) + return mcptypes.CLIOverlay{}, false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + slog.Warn("plugin: CLI overlay contains trailing JSON", + "plugin", p.Manifest.Name, "server", serverKey, "error", err) + return mcptypes.CLIOverlay{}, false + } + if strings.TrimSpace(overlay.ID) == "" { + overlay.ID = serverKey + } + if strings.TrimSpace(overlay.Command) == "" { + overlay.Command = serverKey + } + return overlay, true +} diff --git a/internal/plugin/converter_coverage_test.go b/internal/plugin/converter_coverage_test.go new file mode 100644 index 00000000..b5f51e3a --- /dev/null +++ b/internal/plugin/converter_coverage_test.go @@ -0,0 +1,143 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package plugin + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestResolveCLIOverlayDefaultsAndInlineErrors(t *testing.T) { + plugin := &Plugin{ + Root: t.TempDir(), + Manifest: Manifest{MCPServers: map[string]*MCPServer{ + "empty": {CLI: nil}, + "whitespace": {CLI: json.RawMessage(" \n\t")}, + "fallback": {CLI: json.RawMessage(`{"id":" ","command":""}`)}, + "malformed": {CLI: json.RawMessage(`{`)}, + "unknown": {CLI: json.RawMessage(`{"id":"plugin","unknownField":true}`)}, + "unknownFlag": {CLI: json.RawMessage(`{ + "toolOverrides":{"tool":{"flags":{"value":{"unknownFlagField":true}}}} + }`)}, + "trailing": {CLI: json.RawMessage(`{} {}`)}, + "legacyTools": {CLI: json.RawMessage(`{ + "tools":[{ + "name":"tool","cliName":"leaf","title":"Title","description":"Description", + "isSensitive":true,"category":"read","hidden":true, + "flags":{"value":{"alias":"value-alias","shorthand":"v"}} + }] + }`)}, + }}, + } + + for _, serverKey := range []string{"missing", "empty", "whitespace", "fallback"} { + t.Run(serverKey, func(t *testing.T) { + overlay, ok := plugin.ResolveCLIOverlay(serverKey) + if !ok || overlay.ID != serverKey || overlay.Command != serverKey { + t.Fatalf("ResolveCLIOverlay(%q) = (%#v, %v), want default overlay", serverKey, overlay, ok) + } + }) + } + legacy, ok := plugin.ResolveCLIOverlay("legacyTools") + if !ok || len(legacy.Tools) != 1 || legacy.Tools[0].CLIName != "leaf" || + legacy.Tools[0].Flags["value"].Alias != "value-alias" { + t.Fatalf("ResolveCLIOverlay(legacyTools) = (%#v, %v), want historical tool metadata", legacy, ok) + } + if overlay, ok := plugin.ResolveCLIOverlay("malformed"); ok { + t.Fatalf("ResolveCLIOverlay(malformed) = (%#v, true), want failure", overlay) + } + for _, serverKey := range []string{"unknown", "unknownFlag", "trailing"} { + if overlay, ok := plugin.ResolveCLIOverlay(serverKey); ok { + t.Fatalf("ResolveCLIOverlay(%s) = (%#v, true), want strict failure", serverKey, overlay) + } + } +} + +func TestResolveCLIOverlayRejectsInvalidExternalFiles(t *testing.T) { + root := t.TempDir() + plugin := &Plugin{ + Root: root, + Manifest: Manifest{MCPServers: map[string]*MCPServer{ + "external": {}, + }}, + } + server := plugin.Manifest.MCPServers["external"] + + t.Run("malformed path JSON", func(t *testing.T) { + server.CLI = json.RawMessage(`"unterminated`) + if overlay, ok := plugin.ResolveCLIOverlay("external"); ok { + t.Fatalf("ResolveCLIOverlay() = (%#v, true), want malformed path failure", overlay) + } + }) + + t.Run("blank path", func(t *testing.T) { + server.CLI = json.RawMessage(`" "`) + if overlay, ok := plugin.ResolveCLIOverlay("external"); ok { + t.Fatalf("ResolveCLIOverlay() = (%#v, true), want blank path failure", overlay) + } + }) + + t.Run("missing root", func(t *testing.T) { + plugin.Root = filepath.Join(root, "does-not-exist") + server.CLI = json.RawMessage(`"overlay.json"`) + if overlay, ok := plugin.ResolveCLIOverlay("external"); ok { + t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing root failure", overlay) + } + plugin.Root = root + }) + + t.Run("missing file", func(t *testing.T) { + server.CLI = json.RawMessage(`"missing.json"`) + if overlay, ok := plugin.ResolveCLIOverlay("external"); ok { + t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing file failure", overlay) + } + }) + + t.Run("read error", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(root, "overlay-dir"), 0o700); err != nil { + t.Fatal(err) + } + server.CLI = json.RawMessage(`"overlay-dir"`) + if overlay, ok := plugin.ResolveCLIOverlay("external"); ok { + t.Fatalf("ResolveCLIOverlay() = (%#v, true), want directory read failure", overlay) + } + }) + + t.Run("oversized file", func(t *testing.T) { + path := filepath.Join(root, "oversized.json") + if err := os.WriteFile(path, bytes.Repeat([]byte(" "), maxPluginCLIOverlayBytes+1), 0o600); err != nil { + t.Fatal(err) + } + server.CLI = json.RawMessage(`"oversized.json"`) + if overlay, ok := plugin.ResolveCLIOverlay("external"); ok { + t.Fatalf("ResolveCLIOverlay() = (%#v, true), want oversized file failure", overlay) + } + }) +} + +func TestResolveCLIOverlayExternalFileAppliesFallbackIdentity(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "overlay.json"), []byte(`{ + "id":"", + "command":" ", + "aliases":["conference"] + }`), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{ + Root: root, + Manifest: Manifest{MCPServers: map[string]*MCPServer{ + "server-key": {CLI: json.RawMessage(`"overlay.json"`)}, + }}, + } + + overlay, ok := plugin.ResolveCLIOverlay("server-key") + if !ok || overlay.ID != "server-key" || overlay.Command != "server-key" || + len(overlay.Aliases) != 1 || overlay.Aliases[0] != "conference" { + t.Fatalf("ResolveCLIOverlay() = (%#v, %v), want fallback identity with file metadata", overlay, ok) + } +} diff --git a/internal/plugin/coverage_edges_test.go b/internal/plugin/coverage_edges_test.go index c40a67fd..f75b5126 100644 --- a/internal/plugin/coverage_edges_test.go +++ b/internal/plugin/coverage_edges_test.go @@ -131,22 +131,19 @@ func TestCrossPlatformCoveragePluginConverterEdges(t *testing.T) { t.Fatalf("StdioClients(user) length = %d", len(got)) } descriptors := p.ToServerDescriptors() - if len(descriptors) != 2 { + if len(descriptors) != 1 { t.Fatalf("descriptors length = %d", len(descriptors)) } - seenDefault := false seenOverlay := false for _, d := range descriptors { switch d.Key { - case "http-default": - seenDefault = d.CLI.ID == "http-default" && d.CLI.Command == "http-default" && d.HasCLIMeta case "http-overlay": seenOverlay = d.CLI.ID == "custom" && d.CLI.Command == "run" && d.AuthHeaders["Authorization"] == "Bearer secret" } } - if !seenDefault || !seenOverlay { - t.Fatalf("descriptor defaults/overlay not covered: %#v", descriptors) + if !seenOverlay { + t.Fatalf("valid descriptor overlay not covered: %#v", descriptors) } dataDir := filepath.Join(filepath.Dir(filepath.Dir(root)), "data") if got := expandPluginVars("$"+"{DWS_PLUGIN_ROOT}|$"+"{DWS_PLUGIN_DATA}|$"+"{PLUGIN_TOKEN}", root); got != root+"|"+dataDir+"|secret" { @@ -300,6 +297,7 @@ func TestCrossPlatformCoverageManifestEdges(t *testing.T) { func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) { oldHome := pluginUserHomeDir t.Cleanup(func() { pluginUserHomeDir = oldHome }) + t.Setenv("DWS_CONFIG_DIR", "") home := t.TempDir() pluginUserHomeDir = func() (string, error) { return home, nil } defaultLoader := NewLoader("1.2.3") @@ -310,6 +308,12 @@ func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) { if got := NewLoader("dev").PluginsDir; got != filepath.Join(".dws", "plugins") { t.Fatalf("NewLoader error path = %q", got) } + customConfig := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", customConfig) + if got := NewLoader("dev").PluginsDir; got != filepath.Join(customConfig, "plugins") { + t.Fatalf("NewLoader custom config path = %q", got) + } + t.Setenv("DWS_CONFIG_DIR", "") root := t.TempDir() l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"} diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index cbb8a677..213f5a1c 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -56,9 +56,13 @@ var ( // NewLoader creates a Loader with default paths. func NewLoader(cliVersion string) *Loader { - home, _ := pluginUserHomeDir() + configDir := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR")) + if configDir == "" { + home, _ := pluginUserHomeDir() + configDir = filepath.Join(home, ".dws") + } return &Loader{ - PluginsDir: filepath.Join(home, ".dws", "plugins"), + PluginsDir: filepath.Join(configDir, "plugins"), CLIVersion: cliVersion, } } diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index c110bbb0..cb2f6b50 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -74,6 +74,56 @@ func TestParseManifest(t *testing.T) { } } +func TestResolveCLIOverlayExternalFileIsRootContained(t *testing.T) { + root := t.TempDir() + overlayPath := filepath.Join(root, "overlay.json") + if err := os.WriteFile(overlayPath, []byte(`{ + "id":"external-id", + "command":"external-command", + "toolOverrides":{"ping":{"cliName":"ping"}} + }`), 0o600); err != nil { + t.Fatal(err) + } + loaded := &Plugin{ + Root: root, + Manifest: Manifest{ + Name: "external-plugin", + MCPServers: map[string]*MCPServer{ + "external": { + Type: "streamable-http", + Endpoint: "https://example.invalid/mcp", + CLI: json.RawMessage(`"overlay.json"`), + }, + }, + }, + } + overlay, ok := loaded.ResolveCLIOverlay("external") + if !ok || overlay.ID != "external-id" || overlay.Command != "external-command" { + t.Fatalf("external overlay = (%#v, %v)", overlay, ok) + } + descriptors := loaded.ToServerDescriptors() + if len(descriptors) != 1 || descriptors[0].CLI.ID != "external-id" { + t.Fatalf("external HTTP descriptors = %#v", descriptors) + } + + outside := filepath.Join(t.TempDir(), "outside-overlay.json") + if err := os.WriteFile(outside, []byte(`{"id":"escaped"}`), 0o600); err != nil { + t.Fatal(err) + } + loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"../outside-overlay.json"`) + if overlay, ok := loaded.ResolveCLIOverlay("external"); ok { + t.Fatalf("parent traversal resolved overlay %#v", overlay) + } + + link := filepath.Join(root, "escaped-link.json") + if err := os.Symlink(outside, link); err == nil { + loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"escaped-link.json"`) + if overlay, ok := loaded.ResolveCLIOverlay("external"); ok { + t.Fatalf("escaping symlink resolved overlay %#v", overlay) + } + } +} + func TestManifestValidate(t *testing.T) { tests := []struct { name string @@ -219,8 +269,23 @@ func TestLoadAllSuppressesOptionalPluginValidationWarnings(t *testing.T) { func TestPluginToServerDescriptors(t *testing.T) { cliOverlay, _ := json.Marshal(map[string]any{ - "id": "conference", - "command": "conference", + "id": "conference", + "command": "conference", + "description": "video conference", + "groups": map[string]any{ + "camera": map[string]any{"description": "camera control"}, + }, + "toolOverrides": map[string]any{ + "open_camera": map[string]any{ + "cliName": "open", + "group": "camera", + "description": "open camera", + "isSensitive": true, + "flags": map[string]any{ + "device_id": map[string]any{"description": "camera device"}, + }, + }, + }, }) p := &Plugin{ @@ -262,6 +327,16 @@ func TestPluginToServerDescriptors(t *testing.T) { if d.CLI.ID != "conference" { t.Errorf("cli.id = %q, want conference", d.CLI.ID) } + override := d.CLI.ToolOverrides["open_camera"] + if d.CLI.Description != "video conference" || + d.CLI.Groups["camera"].Description != "camera control" || + override.CLIName != "open" || + override.Group != "camera" || + override.Description != "open camera" || + !override.IsSensitive || + override.Flags["device_id"].Description != "camera device" { + t.Fatalf("CLI overlay command metadata was not preserved: %#v", d.CLI) + } } func TestPluginToServerDescriptorsWithHeaders(t *testing.T) { diff --git a/internal/shortcut/builtin/base_commands_test.go b/internal/shortcut/builtin/base_commands_test.go new file mode 100644 index 00000000..7d0b3e5e --- /dev/null +++ b/internal/shortcut/builtin/base_commands_test.go @@ -0,0 +1,33 @@ +// 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 builtin_test + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin" +) + +func TestBaseCommandsExposeSortedDistributionShortcuts(t *testing.T) { + commands := builtin.BaseCommands() + if len(commands) == 0 { + t.Fatal("BaseCommands returned no distribution shortcuts") + } + for index, command := range commands { + if index > 0 && commands[index-1].Name() > command.Name() { + t.Fatalf("BaseCommands are not sorted: %q before %q", commands[index-1].Name(), command.Name()) + } + children := command.Commands() + if len(children) == 0 { + t.Fatalf("base shortcut service %q has no commands", command.Name()) + } + for _, child := range children { + if !strings.HasPrefix(child.Name(), "+") { + t.Fatalf("base shortcut %q under %q is not mounted as a +command", child.Name(), command.Name()) + } + } + } +} diff --git a/internal/shortcut/builtin/builtin.go b/internal/shortcut/builtin/builtin.go index 85c17919..a2a04051 100644 --- a/internal/shortcut/builtin/builtin.go +++ b/internal/shortcut/builtin/builtin.go @@ -50,3 +50,9 @@ import ( func Commands() []*cobra.Command { return shortcut.Commands() } + +// BaseCommands returns only distribution-owned shortcuts for Schema and +// interface generation. +func BaseCommands() []*cobra.Command { + return shortcut.BuiltInCommands() +} diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 429cd044..96dbe1be 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -17,6 +17,7 @@ import ( "fmt" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // MessagesSend sends a text/markdown message as the current user @@ -242,6 +243,11 @@ var MessagesList = shortcut.Shortcut{ // clean output projection. Both the list container and the per-item field names are // probed defensively across candidate keys, so an empty or unexpected shape // yields an empty list rather than a crash or fabricated data. +// +// Text goes through the shared chatmsg projection so card/auto-reply JSON is +// rendered readable and encrypted ciphertext is marked, and forwarded chat +// records ("聊天记录") expand their nested messages under "forwarded" instead of +// collapsing to a "[卡片]" summary. func listMessagesProject(data map[string]any) []map[string]any { raw := listMessagesResolveList(data) out := make([]map[string]any, 0, len(raw)) @@ -250,29 +256,39 @@ func listMessagesProject(data map[string]any) []map[string]any { if !ok { continue } - row := map[string]any{} - if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { - row["messageId"] = v - } - if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok { - row["senderId"] = v - } - if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok { - row["msgType"] = v - } - if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok { - row["createTime"] = v - } - if v, ok := listMessagesFirst(m, "text", "content", "plainText"); ok { - row["text"] = v - } - if len(row) > 0 { + if row := listMessageProjectOne(m); len(row) > 0 { out = append(out, row) } } return out } +// listMessageProjectOne projects a single message into the native +// {messageId, senderId, msgType, createTime, text(, forwarded)} shape, reused +// recursively for forwarded chat records. +func listMessageProjectOne(m map[string]any) map[string]any { + row := map[string]any{} + if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { + row["messageId"] = v + } + if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok { + row["senderId"] = v + } + if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok { + row["msgType"] = v + } + if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok { + row["createTime"] = v + } + if text := chatmsg.Text(m); text != nil { + row["text"] = text + } + if forwarded := chatmsg.Forwarded(m, listMessageProjectOne); len(forwarded) > 0 { + row["forwarded"] = forwarded + } + return row +} + // listMessagesResolveList locates the list payload, tolerating a bare top-level // array container or nesting one level deeper under a common envelope key. func listMessagesResolveList(data map[string]any) []any { diff --git a/internal/shortcut/chat/chat_message_project_test.go b/internal/shortcut/chat/chat_message_project_test.go new file mode 100644 index 00000000..76437217 --- /dev/null +++ b/internal/shortcut/chat/chat_message_project_test.go @@ -0,0 +1,56 @@ +// 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 chat + +import ( + "strings" + "testing" +) + +const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1" + +func TestListMessageProjectOne(t *testing.T) { + // full field mapping + forwarded expansion; an encrypted body is marked (no + // cross-conversation recovery), not leaked as base64. + row := listMessageProjectOne(map[string]any{ + "openMessageId": "mid", + "senderOpenDingTalkId": "DXYZ", + "msgType": "text", + "createTime": "2026-07-19 13:37:03", + "content": testCipher, + "forwardMessages": []any{ + map[string]any{"openMessageId": "c1", "senderOpenDingTalkId": "DA", "content": "子消息", "createTime": "t"}, + }, + }) + + if row["messageId"] != "mid" || row["senderId"] != "DXYZ" || row["msgType"] != "text" { + t.Fatalf("field mapping = %#v", row) + } + if row["createTime"] != "2026-07-19 13:37:03" { + t.Errorf("createTime = %v", row["createTime"]) + } + if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") || strings.Contains(s, "||2||1||") { + t.Errorf("encrypted text = %v, want marker", row["text"]) + } + fwd, ok := row["forwarded"].([]map[string]any) + if !ok || len(fwd) != 1 || fwd[0]["messageId"] != "c1" || fwd[0]["text"] != "子消息" { + t.Errorf("forwarded = %#v", row["forwarded"]) + } + + // a bare message with no recognizable fields → empty row (no keys) + row = listMessageProjectOne(map[string]any{"unrelated": 1}) + if len(row) != 0 { + t.Errorf("empty message row = %#v, want no keys", row) + } +} diff --git a/internal/shortcut/chat/compatibility_coverage_test.go b/internal/shortcut/chat/compatibility_coverage_test.go index 19d14050..c9becedb 100644 --- a/internal/shortcut/chat/compatibility_coverage_test.go +++ b/internal/shortcut/chat/compatibility_coverage_test.go @@ -16,6 +16,7 @@ package chat import ( "context" "io" + "reflect" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" @@ -129,3 +130,48 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { }) } } + +func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) { + t.Run("recognize open DingTalk IDs", func(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {value: "DingTalk-open-id", want: true}, + {value: " dingtalk-open-id ", want: true}, + {value: "user-id", want: false}, + {value: " ", want: false}, + } + for _, tc := range tests { + if got := isOpenID(tc.value); got != tc.want { + t.Errorf("isOpenID(%q) = %v, want %v", tc.value, got, tc.want) + } + } + }) + + t.Run("split mixed IDs", func(t *testing.T) { + userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"}) + if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) { + t.Fatalf("user IDs = %#v, want %#v", userIDs, want) + } + if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) { + t.Fatalf("open IDs = %#v, want %#v", openIDs, want) + } + }) + + t.Run("parse numeric IDs", func(t *testing.T) { + got, err := toInt64Slice([]string{" 1 ", "", "-2"}) + if err != nil { + t.Fatal(err) + } + if want := []int64{1, -2}; !reflect.DeepEqual(got, want) { + t.Fatalf("numeric IDs = %#v, want %#v", got, want) + } + if _, err := toInt64Slice([]string{"not-a-number"}); err == nil { + t.Fatal("invalid integer unexpectedly succeeded") + } + if _, err := toInt64Slice([]string{"", " "}); err == nil { + t.Fatal("empty integer list unexpectedly succeeded") + } + }) +} diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go new file mode 100644 index 00000000..b1389819 --- /dev/null +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -0,0 +1,292 @@ +// 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 chatmsg holds the shared, read-only projection helpers for DingTalk +// message-list responses (list_individual_chat_message, +// list_conversation_message_v2, search_at_me_message, search_messages_by_keyword, +// list_topic_replies, …). Several shortcuts reshape those raw responses into a +// clean speaker/text/time list; centralising the fiddly bits here keeps them +// consistent and fixed in one place: +// +// - Sender: the display name lives under the bare "sender" key, forwarded +// entries carry the literal string "null", and some responses nest the +// speaker in a {name:…} object — all handled here. +// - Text: out-of-office auto-replies / cards arrive as raw rich-content JSON, +// and card/robot messages arrive as undecryptable ciphertext; CleanText +// renders the former to readable text and marks the latter, WITHOUT ever +// rewriting ordinary text that merely contains a JSON fragment. +// - Forwarded: a forwarded chat record ("聊天记录") hides its real per-message +// bodies in forwardMessages while the top-level content is a lossy summary. +package chatmsg + +import ( + "encoding/json" + "regexp" + "strings" +) + +// Sender reads a message's speaker display name, tolerating common sender-name +// keys. The message-list responses carry the display name under the bare +// "sender" key (verified live), so it is probed first; the remaining aliases and +// the *Id fallbacks keep the projection resilient to other shapes. The literal +// string "null" (forwarded entries) and the empty string are treated as absent, +// and a nested {name:…} sender object yields its display name rather than the +// raw object. +func Sender(m map[string]any) any { + for _, key := range []string{"sender", "senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId", "senderOpenDingTalkId"} { + v, ok := m[key] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case string: + if t == "" || t == "null" { + continue + } + return t + case map[string]any: + // Nested sender object: extract a display-name field; never return + // the raw map (it would surface a JSON object and block fallbacks). + if name := senderDisplayName(t); name != "" { + return name + } + continue + default: + // Scalar id (e.g. numeric) — usable as-is. + return v + } + } + return nil +} + +// senderDisplayName extracts a human name from a nested sender object. +func senderDisplayName(m map[string]any) string { + for _, k := range []string{"name", "nick", "userName", "staffName", "displayName", "senderName"} { + if s, ok := m[k].(string); ok { + if s = strings.TrimSpace(s); s != "" && s != "null" { + return s + } + } + } + return "" +} + +// Text reads a message's textual content (tolerating common text keys and one +// level of nesting) and runs it through CleanText. +func Text(m map[string]any) any { + for _, key := range []string{"text", "content", "msgContent", "message", "body", "plainText"} { + v, ok := m[key] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case string: + if t != "" { + return CleanText(t) + } + case map[string]any: + for _, inner := range []string{"text", "content", "value"} { + if s, ok := t[inner].(string); ok && s != "" { + return CleanText(s) + } + } + } + } + return nil +} + +// CreateTime reads a message's create/send time under whichever candidate key is +// present, returning the raw value. +func CreateTime(m map[string]any) any { + for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +// Forwarded projects the nested messages of a forwarded chat record. The caller +// supplies its own per-message projection so each command keeps its own row +// shape; project is applied recursively, so multi-level forwards expand too. +func Forwarded(m map[string]any, project func(map[string]any) map[string]any) []map[string]any { + raw, ok := m["forwardMessages"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]map[string]any, 0, len(raw)) + for _, e := range raw { + if sub, ok := e.(map[string]any); ok { + out = append(out, project(sub)) + } + } + return out +} + +// CleanText makes a message body human-readable WITHOUT ever rewriting ordinary +// text. It only transforms a body that is a genuine DingTalk structured message: +// +// - Encrypted card/robot ciphertext (base64 + "||v||t||len" trailer) → a clear +// "[加密消息]" marker instead of the raw base64. +// - A rich-content card (out-of-office auto-reply, link/preview card, …) whose +// lines include at least one recognised rich-content block → the readable +// text extracted from those blocks, with the card's decorative JSON lines and +// "empty" placeholders dropped. +// +// Crucially, if NO line is a recognised rich-content block (e.g. ordinary text +// that merely embeds a `{"approved":false}` fragment), the original string is +// returned verbatim — a JSON line is never silently dropped. +func CleanText(s string) string { + if IsEncrypted(s) { + return "[加密消息,无法解码]" + } + + // Fast path: no JSON delimiters at all — the overwhelming common case. + if !strings.ContainsAny(s, "{[") { + return s + } + + lines := strings.Split(s, "\n") + isJSON := make([]bool, len(lines)) + isDecoration := make([]bool, len(lines)) + extracted := make([][]string, len(lines)) + anyExtracted := false + for i, line := range lines { + t := strings.TrimSpace(line) + if !strings.HasPrefix(t, "{") && !strings.HasPrefix(t, "[") { + continue + } + var v any + if json.Unmarshal([]byte(t), &v) != nil { + continue + } + isJSON[i] = true + isDecoration[i] = isKnownRichDecoration(v) + if texts := richItemTexts(v); len(texts) > 0 { + extracted[i] = texts + anyExtracted = true + } + } + + // No recognised rich-content block anywhere → treat the whole body as plain + // text (which may merely contain a JSON fragment) and return it untouched. + if !anyExtracted { + return s + } + + out := make([]string, 0, len(lines)) + for i, line := range lines { + if len(extracted[i]) > 0 { + out = append(out, extracted[i]...) + continue + } + // In card mode, drop only JSON shapes known to be card decoration. + // Unrecognised JSON may be user-authored message content and must remain + // verbatim even when another line contains a rich-content block. + if isJSON[i] && isDecoration[i] { + continue + } + if t := strings.TrimSpace(line); t == "" || t == "empty" { + continue + } + out = append(out, line) + } + // anyExtracted is true here, so out always holds at least one non-empty + // extracted text — the joined result is never empty. + return strings.TrimSpace(strings.Join(out, "\n")) +} + +// isKnownRichDecoration recognises the two decoration records emitted alongside +// DingTalk rich-content bodies. Keep this deliberately narrow: an arbitrary JSON +// object in the same message is user content unless its shape is known here. +func isKnownRichDecoration(node any) bool { + m, ok := node.(map[string]any) + if !ok { + return false + } + _, hasPreviewURL := m["previewUrl"] + _, hasTitle := m["title"] + _, hasAutoLayout := m["autoLayout"] + _, hasEnableForward := m["enableForward"] + return (hasPreviewURL && hasTitle) || (hasAutoLayout && hasEnableForward) +} + +// richItemTexts walks a decoded DingTalk rich-content blob and returns the +// readable text carried by its rich-content items (items[].data.text). It only +// harvests item bodies, so decorative fields (card titles, preview URLs, layout +// config) contribute nothing and are dropped. An empty result means "not a +// recognised rich-content block". +func richItemTexts(node any) []string { + var texts []string + var walk func(n any) + walk = func(n any) { + switch t := n.(type) { + case []any: + for _, e := range t { + walk(e) + } + case map[string]any: + if items, ok := t["items"].([]any); ok { + for _, it := range items { + mm, ok := it.(map[string]any) + if !ok { + continue + } + data, ok := mm["data"].(map[string]any) + if !ok { + continue + } + if s, ok := data["text"].(string); ok { + if s = strings.TrimSpace(s); s != "" { + texts = append(texts, s) + } + } + } + } + for _, e := range t { + walk(e) + } + } + } + walk(node) + return texts +} + +// encryptedTrailerRE matches DingTalk's encrypted-message trailer +// "||||||" (e.g. "||2||1||196") anchored at the end. +var encryptedTrailerRE = regexp.MustCompile(`\|\|\d+\|\|\d+\|\|\d+\s*$`) + +// IsEncrypted reports whether a message body is a raw DingTalk encrypted-message +// ciphertext: a base64 blob (DingTalk wraps it across several lines) followed by +// the "||v||t||len" trailer. It is intentionally strict — both the trailer and a +// pure-base64 body are required — so ordinary text (CJK, punctuation, …) never +// trips it. +func IsEncrypted(s string) bool { + s = strings.TrimSpace(s) + if !encryptedTrailerRE.MatchString(s) { + return false + } + body := strings.TrimSpace(encryptedTrailerRE.ReplaceAllString(s, "")) + if len(body) < 32 { + return false + } + for _, r := range body { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', + r == '+', r == '/', r == '=', r == '\n', r == '\r', r == ' ', r == '\t': + default: + return false + } + } + return true +} diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go new file mode 100644 index 00000000..b9f0c618 --- /dev/null +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -0,0 +1,176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chatmsg + +import ( + "strings" + "testing" +) + +func TestSender(t *testing.T) { + // The display name lives under the bare "sender" key. + if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" { + t.Fatalf("sender = %v, want 念晨", got) + } + // Falls back to the open id when no display name is present. + if got := Sender(map[string]any{"senderOpenDingTalkId": "DXYZ"}); got != "DXYZ" { + t.Fatalf("sender fallback = %v, want DXYZ", got) + } + // forwardMessages entries carry the literal string "null" — treat as absent. + if got := Sender(map[string]any{"sender": "null"}); got != nil { + t.Fatalf("sender \"null\" = %v, want nil", got) + } + if got := Sender(map[string]any{"sender": "null", "senderName": "念晨"}); got != "念晨" { + t.Fatalf("sender \"null\" fallthrough = %v, want 念晨", got) + } + // A nested {name:…} sender object yields its display name, not the raw map. + if got := Sender(map[string]any{"sender": map[string]any{"name": "Alice"}}); got != "Alice" { + t.Fatalf("nested sender = %v, want Alice", got) + } + // A nested sender object with no usable name must not block the fallback. + if got := Sender(map[string]any{"sender": map[string]any{"foo": "bar"}, "senderName": "Bob"}); got != "Bob" { + t.Fatalf("nested-no-name fallthrough = %v, want Bob", got) + } + // A scalar numeric id is returned as-is. + if got := Sender(map[string]any{"senderId": float64(42)}); got != float64(42) { + t.Fatalf("numeric sender id = %v", got) + } +} + +func TestCleanText(t *testing.T) { + // Out-of-office auto-reply: readable body lives in items[].data.text; the + // decorative preview/config JSON lines and "empty" placeholder are dropped. + autoReply := "* 仅你和对方可见\n" + + `[{"text":{"minSupportVersion":"1.1","translateMap":{},"version":"1.2","items":[{"fallbackKey":"","data":{"text":"你好,我在出差中,消息回复可能不及时。"},"style":{"size":15,"bold":0},"type":"text"}]},"type":"markdown"}]` + "\n" + + `{"previewUrl":"dingtalk://x","title":{"text":"自动回复","type":"text"}}` + "\n" + + "empty\n" + + `{"autoLayout":false,"enableForward":false}` + if got, want := CleanText(autoReply), "* 仅你和对方可见\n你好,我在出差中,消息回复可能不及时。"; got != want { + t.Fatalf("auto-reply cleaned = %q, want %q", got, want) + } + + // P1 regression: ordinary text whose middle line is a JSON fragment (no + // rich-content block anywhere) must be returned VERBATIM, not rewritten. + mixed := "payload:\n{\"approved\":false}\nplease check" + if got := CleanText(mixed); got != mixed { + t.Fatalf("mixed text was rewritten: got %q, want %q", got, mixed) + } + + // An ordinary JSON line must also survive when a different line contains a + // recognised rich-content block. Card mode is not permission to discard + // unrelated user-authored JSON. + richAndPlain := `[{"items":[{"data":{"text":"卡片正文"}}]}]` + "\n" + + `{"approved":false}` + if got, want := CleanText(richAndPlain), "卡片正文\n{\"approved\":false}"; got != want { + t.Fatalf("mixed rich/plain JSON was rewritten: got %q, want %q", got, want) + } + + // Malformed items (non-map item, item whose "data" isn't a map) are skipped; + // only the well-formed item's text is extracted. + blob := `[{"items":["notmap",{"data":"notmap"},{"data":{"text":"有效正文"}}]}]` + if got := CleanText(blob); got != "有效正文" { + t.Fatalf("CleanText rich edge = %q, want 有效正文", got) + } + + tests := map[string]string{ + "上周五 7.1 KW": "上周五 7.1 KW", + "上周客户统计的[图片消息](mediaId=@lQ)": "上周客户统计的[图片消息](mediaId=@lQ)", + "[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载": "[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载", + "[讨论] 排期\n明天开会": "[讨论] 排期\n明天开会", + // a lone JSON object that isn't a rich-content block is left untouched + `{"autoLayout":false,"enableForward":false}`: `{"autoLayout":false,"enableForward":false}`, + } + for in, want := range tests { + if got := CleanText(in); got != want { + t.Errorf("CleanText(%q) = %q, want %q", in, got, want) + } + } +} + +func TestIsEncryptedAndMarker(t *testing.T) { + cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" + + "7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" + + "||2||1||196" + if !IsEncrypted(cipher) { + t.Fatalf("ciphertext not detected: %q", cipher) + } + if got := CleanText(cipher); !strings.Contains(got, "加密消息") || strings.Contains(got, "||2||1||") { + t.Fatalf("encrypted cleaned = %q, want marker not ciphertext", got) + } + for _, s := range []string{ + "上周五 7.1 KW", + "价格 100||2||1||3", + "[图片消息](mediaId=@lQLPJwDw3VmNDcfMos0DhLB3OHPQeTBlzgov2Oi1ly4A)", + "大哥,我看了一下我觉得有几个点可以关注一下", + strings.Repeat("好", 20) + "||2||1||1", // long CJK body + trailer, not base64 + } { + if IsEncrypted(s) { + t.Errorf("false positive: %q flagged as encrypted", s) + } + } +} + +func TestText(t *testing.T) { + if got := Text(map[string]any{"content": "你好"}); got != "你好" { + t.Errorf("Text string = %v", got) + } + if got := Text(map[string]any{"content": map[string]any{"text": "嵌套"}}); got != "嵌套" { + t.Errorf("Text nested = %v", got) + } + if got := Text(map[string]any{"plainText": "纯文本"}); got != "纯文本" { + t.Errorf("Text plainText = %v", got) + } + if got := Text(map[string]any{"foo": 1}); got != nil { + t.Errorf("Text none = %v, want nil", got) + } +} + +func TestCreateTime(t *testing.T) { + if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" { + t.Errorf("CreateTime = %v", got) + } + if got := CreateTime(map[string]any{}); got != nil { + t.Errorf("CreateTime empty = %v, want nil", got) + } +} + +func TestForwarded(t *testing.T) { + var project func(m map[string]any) map[string]any + project = func(m map[string]any) map[string]any { + row := map[string]any{"text": Text(m)} + if fwd := Forwarded(m, project); len(fwd) > 0 { // recurse + row["forwarded"] = fwd + } + return row + } + if got := Forwarded(map[string]any{"content": "x"}, project); got != nil { + t.Errorf("Forwarded none = %v", got) + } + fwd := Forwarded(map[string]any{ + "forwardMessages": []any{ + map[string]any{"content": "a"}, + "not-a-map", + map[string]any{"content": "b", "forwardMessages": []any{ + map[string]any{"content": "nested"}, + }}, + }, + }, project) + if len(fwd) != 2 || fwd[0]["text"] != "a" || fwd[1]["text"] != "b" { + t.Fatalf("Forwarded = %#v", fwd) + } + nested, ok := fwd[1]["forwarded"].([]map[string]any) + if !ok || len(nested) != 1 || nested[0]["text"] != "nested" { + t.Errorf("nested forwarded = %#v", fwd[1]["forwarded"]) + } +} diff --git a/internal/shortcut/register.go b/internal/shortcut/register.go index c35e08c9..8fda03d4 100644 --- a/internal/shortcut/register.go +++ b/internal/shortcut/register.go @@ -41,6 +41,17 @@ func Commands() []*cobra.Command { return build(allShortcuts) } +// BuiltInCommands compiles only distribution-owned shortcuts. +func BuiltInCommands() []*cobra.Command { + builtins := make([]Shortcut, 0, len(allShortcuts)) + for _, registered := range allShortcuts { + if !registered.UserDefined { + builtins = append(builtins, registered) + } + } + return build(builtins) +} + // All returns the registered shortcuts. Primarily for coverage tests that need // each shortcut's declared flags (types/enums/required) to synthesize inputs. func All() []Shortcut { diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 847bcfc7..1c0a4c27 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -266,6 +266,26 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) { } } +func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) { + previous := append([]Shortcut(nil), allShortcuts...) + t.Cleanup(func() { allShortcuts = previous }) + allShortcuts = nil + Register( + Shortcut{Service: "calendar", Command: "+builtin", Execute: noop}, + Shortcut{Service: "calendar", Command: "+user", UserDefined: true, Execute: noop}, + ) + + all := Commands() + if len(all) != 1 || len(all[0].Commands()) != 2 { + t.Fatalf("all shortcut commands = %#v", all) + } + builtins := BuiltInCommands() + if len(builtins) != 1 || len(builtins[0].Commands()) != 1 || + builtins[0].Commands()[0].Name() != "+builtin" { + t.Fatalf("built-in shortcut commands = %#v", builtins) + } +} + func noop(_ *RuntimeContext) error { return nil } func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) { diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 904a3c59..93de75df 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -19,6 +19,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // AtMe: pull the messages that recently @-mentioned ME across chats in one step. @@ -91,12 +92,7 @@ var AtMe = shortcut.Shortcut{ } results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": atMeSender(m), - "time": atMeTime(m), - "text": atMeText(m), - "conversation": atMeConversation(m), - }) + results = append(results, atMeProject(m)) } return rt.Output(map[string]any{"messages": results}) }, @@ -194,28 +190,62 @@ func atMeToMaps(arr []any) []map[string]any { return out } +// atMeProject reshapes one @me message into {sender, time, text, conversation}, +// running text through the shared chatmsg cleaning (card/auto-reply JSON → +// readable, ciphertext → marker) and recursively expanding any forwarded chat +// record under "forwarded". +func atMeProject(m map[string]any) map[string]any { + row := map[string]any{ + "sender": atMeSender(m), + "time": atMeTime(m), + "text": atMeCleanText(m), + "conversation": atMeConversation(m), + } + if forwarded := chatmsg.Forwarded(m, atMeProject); len(forwarded) > 0 { + row["forwarded"] = forwarded + } + return row +} + +// atMeCleanText runs atMeText's extraction through chatmsg.CleanText so +// card/auto-reply JSON and ciphertext render readable instead of leaking raw. +func atMeCleanText(m map[string]any) any { + if s, ok := atMeText(m).(string); ok { + return chatmsg.CleanText(s) + } + return atMeText(m) +} + // atMeSender reads a message's sender display name/id, tolerating the common -// sender keys the gateway may use (including a nested sender object). +// sender keys the gateway may use (including a nested sender object). The literal +// string "null" (carried by forwarded sub-messages) and the empty string are +// both treated as absent so they never surface as the speaker. func atMeSender(m map[string]any) any { + norm := func(v any) string { + if s := atMeString(v); s != "" && s != "null" { + return s + } + return "" + } for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} { - if v := atMeString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } for _, key := range []string{"sender", "from", "senderUser"} { if nested, ok := m[key].(map[string]any); ok { for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} { - if v := atMeString(nested[k2]); v != "" { + if v := norm(nested[k2]); v != "" { return v } } } - if v := atMeString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} { - if v := atMeString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } diff --git a/internal/shortcut/smart/at_me_search_msg_test.go b/internal/shortcut/smart/at_me_search_msg_test.go new file mode 100644 index 00000000..b5f389f9 --- /dev/null +++ b/internal/shortcut/smart/at_me_search_msg_test.go @@ -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 smart + +import ( + "strings" + "testing" +) + +const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1" + +func TestAtMeProject(t *testing.T) { + // nested sender object + plain text + row := atMeProject(map[string]any{ + "sender": map[string]any{"name": "念晨"}, + "createTime": "2026-07-19 13:37:03", + "content": "普通消息", + "conversationTitle": "群A", + "openConversationId": "cid1", + }) + if row["sender"] != "念晨" || row["text"] != "普通消息" || row["conversation"] != "群A" { + t.Fatalf("atMeProject nested = %#v", row) + } + + // encrypted content → marked (never leaked); id-only sender fallback; a + // forwarded sub-message whose sender is the literal "null" must be nulled. + row = atMeProject(map[string]any{ + "senderId": "DXYZ", + "openMessageId": "m1", + "content": testCipher, + "forwardMessages": []any{ + map[string]any{"sender": "null", "content": "子消息", "createTime": "t"}, + }, + }) + if row["sender"] != "DXYZ" { + t.Errorf("atMeProject id-fallback sender = %v", row["sender"]) + } + if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") { + t.Errorf("atMeProject encrypted text = %v, want marker", row["text"]) + } + fwd, ok := row["forwarded"].([]map[string]any) + if !ok || len(fwd) != 1 { + t.Fatalf("atMeProject forwarded = %#v", row["forwarded"]) + } + if fwd[0]["sender"] != nil { + t.Errorf("forwarded sub sender = %v, want nil (literal \"null\")", fwd[0]["sender"]) + } + + // no sender / no text at all → nils, no forwarded key + row = atMeProject(map[string]any{"createTime": "t"}) + if row["sender"] != nil || row["text"] != nil { + t.Errorf("atMeProject empty = %#v", row) + } + if _, has := row["forwarded"]; has { + t.Errorf("atMeProject plain unexpectedly has forwarded") + } +} + +func TestSearchMsgProject(t *testing.T) { + // nested sender + plain text + messageId + row := searchMsgProject(map[string]any{ + "sender": map[string]any{"nick": "千启"}, + "createTime": "2026-07-19 13:37:03", + "content": "命中关键词的消息", + "msgId": "mid1", + }) + if row["sender"] != "千启" || row["text"] != "命中关键词的消息" { + t.Fatalf("searchMsgProject = %#v", row) + } + + // encrypted → marker; id-only sender; forwarded "null" sender nulled. + row = searchMsgProject(map[string]any{ + "senderId": "DAAA", + "openMessageId": "m2", + "content": testCipher, + "forwardMessages": []any{ + map[string]any{"sender": "null", "content": "转发子消息", "createTime": "t"}, + }, + }) + if row["sender"] != "DAAA" { + t.Errorf("searchMsgProject sender = %v", row["sender"]) + } + if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") { + t.Errorf("searchMsgProject encrypted text = %v, want marker", row["text"]) + } + fwd, ok := row["forwarded"].([]map[string]any) + if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil { + t.Errorf("searchMsgProject forwarded = %#v", row["forwarded"]) + } + + // no sender / no text + row = searchMsgProject(map[string]any{"createTime": "t"}) + if row["sender"] != nil || row["text"] != nil { + t.Errorf("searchMsgProject empty = %#v", row) + } +} + +// TestSenderHelpers exercises the atMe/searchMsg sender key families directly: +// a senderName-family key (first probe loop), a flat string under "sender" +// (second loop), and the "null" sentinel normalisation. +func TestSenderHelpers(t *testing.T) { + cases := []struct { + fn func(map[string]any) any + name string + }{ + {atMeSender, "atMeSender"}, + {searchMsgSender, "searchMsgSender"}, + } + for _, c := range cases { + if got := c.fn(map[string]any{"senderName": "张三"}); got != "张三" { + t.Errorf("%s senderName = %v, want 张三", c.name, got) + } + if got := c.fn(map[string]any{"sender": "李四"}); got != "李四" { + t.Errorf("%s flat sender = %v, want 李四", c.name, got) + } + if got := c.fn(map[string]any{"senderName": "null"}); got != nil { + t.Errorf("%s \"null\" = %v, want nil", c.name, got) + } + } +} diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index e183a063..3cac53ec 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // ChatMessages: fetch the message list of one conversation (group OR single @@ -111,11 +112,7 @@ var ChatMessages = shortcut.Shortcut{ items := chatMessageItems(data) results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": chatMessageSender(m), - "text": chatMessageText(m), - "createTime": chatMessageCreateTime(m), - }) + results = append(results, projectChatMessage(m)) } return rt.Output(map[string]any{ @@ -156,53 +153,20 @@ func chatMessageItems(data map[string]any) []map[string]any { return nil } -// chatMessageSender reads a message's speaker display name, tolerating common -// sender-name keys. -func chatMessageSender(m map[string]any) any { - for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} { - if v, ok := m[key]; ok && v != nil { - if s, ok := v.(string); ok && s == "" { - continue - } - return v - } +// projectChatMessage reshapes one raw message into the clean +// {sender, text, createTime} projection, rendering card/auto-reply JSON and +// marking encrypted messages via chatmsg, and recursively expanding forwarded +// chat records under "forwarded". +func projectChatMessage(m map[string]any) map[string]any { + row := map[string]any{ + "sender": chatmsg.Sender(m), + "text": chatmsg.Text(m), + "createTime": chatmsg.CreateTime(m), } - return nil -} - -// chatMessageText reads a message's textual content, tolerating common text -// keys and one level of nesting (e.g. {"content":{"text":"..."}}). -func chatMessageText(m map[string]any) any { - for _, key := range []string{"text", "content", "msgContent", "message", "body"} { - v, ok := m[key] - if !ok || v == nil { - continue - } - switch t := v.(type) { - case string: - if t != "" { - return t - } - case map[string]any: - for _, inner := range []string{"text", "content", "value"} { - if s, ok := t[inner].(string); ok && s != "" { - return s - } - } - } + if forwarded := chatmsg.Forwarded(m, projectChatMessage); len(forwarded) > 0 { + row["forwarded"] = forwarded } - return nil -} - -// chatMessageCreateTime reads a message's create/send time, returning the raw -// value under whichever candidate key is present. -func chatMessageCreateTime(m map[string]any) any { - for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { - if v, ok := m[key]; ok && v != nil { - return v - } - } - return nil + return row } func init() { diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go new file mode 100644 index 00000000..f0cb7614 --- /dev/null +++ b/internal/shortcut/smart/chat_messages_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smart + +import "testing" + +// TestProjectChatMessageExpandsForwarded guards that a forwarded chat record +// ("聊天记录") exposes its nested messages under "forwarded" instead of +// collapsing to the lossy top-level "[卡片]" summary, recursing through nested +// forwards, and that the string-"null" sender is nulled out. The per-field +// behaviour (sender/text/encryption) is covered in the chatmsg package tests. +func TestProjectChatMessageExpandsForwarded(t *testing.T) { + row := projectChatMessage(map[string]any{ + "sender": "hugozhu", + "content": "hugozhu与opencode-agent的聊天记录\nopencode-agent:[卡片]", + "createTime": "2026-07-20 21:41:21", + "forwardMessages": []any{ + map[string]any{"sender": "null", "content": "读下冬翔发给我的最近两条消息", "createTime": "2026-07-20 09:30:33"}, + map[string]any{"sender": "冬翔", "content": "W29 工作总结", "createTime": "2026-07-19 23:35:40", + // nested forward inside a forward — must expand recursively. + "forwardMessages": []any{ + map[string]any{"sender": "念晨", "content": "收到", "createTime": "2026-07-19 23:36:00"}, + }, + }, + }, + }) + + if row["sender"] != "hugozhu" { + t.Fatalf("top sender = %v, want hugozhu", row["sender"]) + } + forwarded, ok := row["forwarded"].([]map[string]any) + if !ok || len(forwarded) != 2 { + t.Fatalf("forwarded = %#v, want 2 entries", row["forwarded"]) + } + if forwarded[0]["sender"] != nil { + t.Errorf("forwarded[0].sender = %v, want nil (string \"null\")", forwarded[0]["sender"]) + } + if forwarded[0]["text"] != "读下冬翔发给我的最近两条消息" { + t.Errorf("forwarded[0].text = %v", forwarded[0]["text"]) + } + nested, ok := forwarded[1]["forwarded"].([]map[string]any) + if !ok || len(nested) != 1 || nested[0]["sender"] != "念晨" { + t.Errorf("nested forwarded = %#v, want 1 entry from 念晨", forwarded[1]["forwarded"]) + } + + // A plain message must not grow a "forwarded" key. + plain := projectChatMessage(map[string]any{"sender": "念晨", "content": "hi", "createTime": "t"}) + if _, has := plain["forwarded"]; has { + t.Errorf("plain message unexpectedly has forwarded key: %#v", plain) + } +} diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 6a544d3d..29e1c80b 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -19,6 +19,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // SearchMsg: search messages inside a single group chat by keyword in one step. @@ -108,12 +109,7 @@ var SearchMsg = shortcut.Shortcut{ } results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": searchMsgSender(m), - "time": searchMsgTime(m), - "text": searchMsgText(m), - "messageId": searchMsgMessageID(m), - }) + results = append(results, searchMsgProject(m)) } return rt.Output(map[string]any{"messages": results}) }, @@ -152,28 +148,61 @@ func searchMsgToMaps(arr []any) []map[string]any { return out } +// searchMsgProject reshapes one matched message into {sender, time, text, +// messageId}, running text through the shared chatmsg cleaning (card/auto-reply +// JSON → readable, ciphertext → marker) and recursively expanding any forwarded +// chat record under "forwarded". +func searchMsgProject(m map[string]any) map[string]any { + row := map[string]any{ + "sender": searchMsgSender(m), + "time": searchMsgTime(m), + "text": searchMsgCleanText(m), + "messageId": searchMsgMessageID(m), + } + if forwarded := chatmsg.Forwarded(m, searchMsgProject); len(forwarded) > 0 { + row["forwarded"] = forwarded + } + return row +} + +// searchMsgCleanText runs searchMsgText's extraction through chatmsg.CleanText. +func searchMsgCleanText(m map[string]any) any { + if s, ok := searchMsgText(m).(string); ok { + return chatmsg.CleanText(s) + } + return searchMsgText(m) +} + // searchMsgSender reads a message's sender display name/id, tolerating the -// common sender keys the gateway may use (including a nested sender object). +// common sender keys the gateway may use (including a nested sender object). The +// literal string "null" (carried by forwarded sub-messages) and the empty string +// are both treated as absent so they never surface as the speaker. func searchMsgSender(m map[string]any) any { + norm := func(v any) string { + if s := searchMsgString(v); s != "" && s != "null" { + return s + } + return "" + } for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} { - if v := searchMsgString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } for _, key := range []string{"sender", "from", "senderUser"} { if nested, ok := m[key].(map[string]any); ok { for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} { - if v := searchMsgString(nested[k2]); v != "" { + if v := norm(nested[k2]); v != "" { return v } } } - if v := searchMsgString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} { - if v := searchMsgString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go index e1854db3..2dc1c25a 100644 --- a/internal/shortcut/smart/thread_replies.go +++ b/internal/shortcut/smart/thread_replies.go @@ -78,11 +78,7 @@ var ThreadReplies = shortcut.Shortcut{ items := threadReplyItems(data) results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": threadReplySender(m), - "text": threadReplyText(m), - "createTime": threadReplyCreateTime(m), - }) + results = append(results, projectChatMessage(m)) } return rt.Output(map[string]any{ @@ -124,55 +120,6 @@ func threadReplyItems(data map[string]any) []map[string]any { return nil } -// threadReplySender reads a reply's speaker display name, tolerating common -// sender-name keys. -func threadReplySender(m map[string]any) any { - for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} { - if v, ok := m[key]; ok && v != nil { - if s, ok := v.(string); ok && s == "" { - continue - } - return v - } - } - return nil -} - -// threadReplyText reads a reply's textual content, tolerating common text keys -// and one level of nesting (e.g. {"text":{"content":"..."}}). -func threadReplyText(m map[string]any) any { - for _, key := range []string{"text", "content", "msgContent", "message", "body"} { - v, ok := m[key] - if !ok || v == nil { - continue - } - switch t := v.(type) { - case string: - if t != "" { - return t - } - case map[string]any: - for _, inner := range []string{"content", "text", "value"} { - if s, ok := t[inner].(string); ok && s != "" { - return s - } - } - } - } - return nil -} - -// threadReplyCreateTime reads a reply's create/send time, returning the raw -// value under whichever candidate key is present. -func threadReplyCreateTime(m map[string]any) any { - for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { - if v, ok := m[key]; ok && v != nil { - return v - } - } - return nil -} - func init() { shortcut.Register(ThreadReplies) } diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 301ae581..039d766c 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -127,6 +127,10 @@ type Shortcut struct { Tips []string // Hidden hides the command from listings while keeping it invocable. Hidden bool + // UserDefined identifies shortcuts loaded from the user's config + // directory. Distribution-owned Schema and interface snapshots exclude + // these runtime extensions even if another root loaded them earlier. + UserDefined bool // Validate optionally checks resolved flag values before execution. Return a // non-nil error to abort with a validation message. Runs after built-in diff --git a/internal/shortcut/userdef/loader.go b/internal/shortcut/userdef/loader.go index fabebd79..ed2ffd4a 100644 --- a/internal/shortcut/userdef/loader.go +++ b/internal/shortcut/userdef/loader.go @@ -196,6 +196,7 @@ func Compile(s Spec) shortcut.Shortcut { Intent: intent, Risk: risk, Flags: flags, + UserDefined: true, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{} for key, tmpl := range bind { diff --git a/internal/shortcut/userdef/loader_test.go b/internal/shortcut/userdef/loader_test.go index fd802cf5..e144c4ce 100644 --- a/internal/shortcut/userdef/loader_test.go +++ b/internal/shortcut/userdef/loader_test.go @@ -69,6 +69,9 @@ func TestCrossPlatformCoverageCompileFlagsAndDefaults(t *testing.T) { if sc.Service != "chat" || sc.Command != "+notify-team" { t.Fatalf("bad identity: %+v", sc) } + if !sc.UserDefined { + t.Fatal("compiled user shortcut is missing user-defined provenance") + } if sc.Risk != shortcut.RiskRead { t.Errorf("risk default = %q, want read", sc.Risk) } diff --git a/pkg/asynctask/asynctask.go b/pkg/asynctask/asynctask.go index 653f2411..1b7e0cc6 100644 --- a/pkg/asynctask/asynctask.go +++ b/pkg/asynctask/asynctask.go @@ -20,8 +20,8 @@ // 2. 渐进式退避(1s/2s/5s/10s/15s/30s/30s...,可自定义) // 3. 默认 5 分钟超时,超时返回 jobId 让用户用 `... get --job-id ` 续等 // 4. 状态机统一:PROCESSING/PENDING → SUCCESS → 拿 downloadUrl -// ↓ -// FAILED → 返回 error +// ↓ +// FAILED → 返回 error // 5. 可选 PUT 下载:传入 OutputPath 时本包负责 HTTP GET 落盘 package asynctask diff --git a/pkg/asynctask/asynctask_test.go b/pkg/asynctask/asynctask_test.go index 4e6e0e10..dd2faff3 100644 --- a/pkg/asynctask/asynctask_test.go +++ b/pkg/asynctask/asynctask_test.go @@ -112,7 +112,9 @@ func TestSubmit_ContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() submitFn := func(ctx context.Context) (string, error) { return "job-x", nil } - queryFn := func(ctx context.Context, jobID string) (QueryResult, error) { return QueryResult{Status: StatusProcessing}, nil } + queryFn := func(ctx context.Context, jobID string) (QueryResult, error) { + return QueryResult{Status: StatusProcessing}, nil + } _, err := Submit(ctx, submitFn, queryFn, Options{Backoff: testBackoff, Timeout: 1 * time.Second}) if !errors.Is(err, context.Canceled) { t.Fatalf("expect ctx.Canceled, got %v", err) diff --git a/pkg/cmdutil/provenance.go b/pkg/cmdutil/provenance.go index 2ca07184..b2dba584 100644 --- a/pkg/cmdutil/provenance.go +++ b/pkg/cmdutil/provenance.go @@ -23,15 +23,28 @@ const SourceAnnotation = "dws.source" // SourceEnvelope marks a command as authored by the runtime discovery envelope. const SourceEnvelope = "envelope" +// SourcePlugin marks a command as an installed plugin extension. Plugin +// commands are part of the runtime CLI surface, not the embedded base Schema. +const SourcePlugin = "plugin" + // MarkEnvelopeSource stamps cmd with runtime discovery provenance. func MarkEnvelopeSource(cmd *cobra.Command) { + markSource(cmd, SourceEnvelope) +} + +// MarkPluginSource stamps cmd with installed-plugin provenance. +func MarkPluginSource(cmd *cobra.Command) { + markSource(cmd, SourcePlugin) +} + +func markSource(cmd *cobra.Command, source string) { if cmd == nil { return } if cmd.Annotations == nil { cmd.Annotations = map[string]string{} } - cmd.Annotations[SourceAnnotation] = SourceEnvelope + cmd.Annotations[SourceAnnotation] = source } // IsEnvelopeSourced reports whether cmd was authored by the runtime discovery @@ -40,6 +53,11 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool { return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope } +// IsPluginSourced reports whether cmd came from an installed plugin. +func IsPluginSourced(cmd *cobra.Command) bool { + return cmd != nil && cmd.Annotations[SourceAnnotation] == SourcePlugin +} + // KindAnnotation is the annotation key for marking command kinds. const KindAnnotation = "dws.kind" diff --git a/pkg/cmdutil/provenance_test.go b/pkg/cmdutil/provenance_test.go index 050b35e1..f9d34148 100644 --- a/pkg/cmdutil/provenance_test.go +++ b/pkg/cmdutil/provenance_test.go @@ -43,3 +43,19 @@ func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) { t.Parallel() MarkEnvelopeSource(nil) } + +func TestPluginSourceProvenance(t *testing.T) { + t.Parallel() + if IsPluginSourced(nil) { + t.Fatal("nil command should not be plugin sourced") + } + cmd := &cobra.Command{Use: "conference"} + MarkPluginSource(cmd) + if !IsPluginSourced(cmd) || IsEnvelopeSourced(cmd) { + t.Fatalf("plugin source annotation = %#v", cmd.Annotations) + } + if got := cmd.Annotations[SourceAnnotation]; got != SourcePlugin { + t.Fatalf("SourceAnnotation = %q, want %q", got, SourcePlugin) + } + MarkPluginSource(nil) +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index a6084374..b3d79f62 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -39,8 +39,24 @@ func defaultHooks() *Hooks { base["claw-type"] = DefaultOSSClawType return base }, - StaticServers: openStaticServers, - VisibleProducts: openVisibleProducts, + StaticServers: openStaticServers, + SupplementServers: openSupplementServers, + VisibleProducts: openVisibleProducts, + } +} + +// openSupplementServers returns helper-only MCP endpoints owned by the open +// CLI. They are callable by explicit server ID but are deliberately excluded +// from VisibleProducts, so no top-level product command is generated. They +// stay separate from syncdata.StaticServers because only explicitly wired CLI +// helpers may call them; they are not public MCP product surfaces. +func openSupplementServers() []ServerInfo { + return []ServerInfo{ + { + ID: "mcp-meta", + Name: "MCP 元服务", + Endpoint: "https://mcp-gw.dingtalk.com/server/89833ea5debf30c260a07ffcb5127ffa3bf0c830cd76babadb293d9861485d44", + }, } } diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go index 6b5faffa..2412b20b 100644 --- a/pkg/edition/edition_test.go +++ b/pkg/edition/edition_test.go @@ -70,4 +70,24 @@ func TestOpenVisibleProductsExcludesCompatibilityOnlyCommands(t *testing.T) { t.Fatal("conference must remain compatibility-only and not be added to StaticServers") } } + if byID["mcp-meta"] { + t.Fatal("mcp-meta is helper-only and must not appear in VisibleProducts") + } +} + +func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { + servers := openSupplementServers() + for _, server := range servers { + if server.ID != "mcp-meta" { + continue + } + if server.Endpoint == "" { + t.Fatal("mcp-meta has empty endpoint") + } + if len(server.Prefixes) != 0 { + t.Fatal("mcp-meta must remain helper-only without command prefixes") + } + return + } + t.Fatal("openSupplementServers() missing mcp-meta") } diff --git a/pkg/mcptypes/types.go b/pkg/mcptypes/types.go index e561819a..a32928c1 100644 --- a/pkg/mcptypes/types.go +++ b/pkg/mcptypes/types.go @@ -1,6 +1,10 @@ package mcptypes -import "encoding/json" +import ( + "bytes" + "encoding/json" + "strings" +) type ServerDescriptor struct { Key string @@ -16,19 +20,110 @@ type ServerDescriptor struct { type CLIOverlay struct { ID string `json:"id"` Command string `json:"command"` + Parent string `json:"parent,omitempty"` + Description string `json:"description,omitempty"` Aliases []string `json:"aliases"` Prefixes []string `json:"prefixes"` + Group string `json:"group,omitempty"` Skip bool `json:"skip"` + Hidden bool `json:"hidden,omitempty"` Tools []CLITool `json:"tools"` + Groups map[string]CLIGroupDef `json:"groups,omitempty"` ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"` + ServerDeps []string `json:"serverDeps,omitempty"` + Hints map[string]json.RawMessage `json:"hintCommands,omitempty"` + RedirectTo string `json:"redirectTo,omitempty"` +} + +type CLIGroupDef struct { + Description string `json:"description,omitempty"` } type CLITool struct { - Name string `json:"name"` + Name string `json:"name"` + CLIName string `json:"cliName,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + IsSensitive bool `json:"isSensitive,omitempty"` + Category string `json:"category,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Flags map[string]CLIFlagHint `json:"flags,omitempty"` +} + +type CLIFlagHint struct { + Shorthand string `json:"shorthand,omitempty"` + Alias string `json:"alias,omitempty"` } type CLIToolOverride struct { - ServerOverride string `json:"serverOverride,omitempty"` + CLIName string `json:"cliName,omitempty"` + CLIAliases []string `json:"cliAliases,omitempty"` + Description string `json:"description,omitempty"` + Example string `json:"example,omitempty"` + Group string `json:"group,omitempty"` + IsSensitive bool `json:"isSensitive,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Flags map[string]CLIFlagOverride `json:"flags,omitempty"` + OutputFormat map[string]any `json:"outputFormat,omitempty"` + ServerOverride string `json:"serverOverride,omitempty"` + BodyWrapper string `json:"bodyWrapper,omitempty"` + MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"` + RequireOneOf [][]string `json:"requireOneOf,omitempty"` + RequireTogether [][]string `json:"requireTogether,omitempty"` + RejectPositional bool `json:"rejectPositional,omitempty"` + RedirectTo string `json:"redirectTo,omitempty"` + Pipeline []json.RawMessage `json:"pipeline,omitempty"` +} + +type CLIFlagOverride struct { + Alias string `json:"alias,omitempty"` + Aliases []string `json:"aliases,omitempty"` + MapsTo string `json:"mapsTo,omitempty"` + Transform string `json:"transform,omitempty"` + TransformArgs map[string]any `json:"transformArgs,omitempty"` + EnvDefault string `json:"envDefault,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Default string `json:"default,omitempty"` + Shorthand string `json:"shorthand,omitempty"` + Required bool `json:"required,omitempty"` + Description string `json:"description,omitempty"` + Positional bool `json:"positional,omitempty"` + PositionalIndex int `json:"positionalIndex,omitempty"` + Type string `json:"type,omitempty"` + OmitWhen string `json:"omitWhen,omitempty"` + RuntimeDefault string `json:"runtimeDefault,omitempty"` + PipelineLocal bool `json:"pipelineLocal,omitempty"` +} + +func (override *CLIFlagOverride) UnmarshalJSON(data []byte) error { + type alias CLIFlagOverride + aux := struct { + Default json.RawMessage `json:"default,omitempty"` + *alias + }{alias: (*alias)(override)} + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&aux); err != nil { + return err + } + override.Default = coercePluginScalar(aux.Default) + return nil +} + +func coercePluginScalar(raw json.RawMessage) string { + value := strings.TrimSpace(string(raw)) + if value == "" || value == "null" || + strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") { + return "" + } + if strings.HasPrefix(value, `"`) { + var decoded string + if json.Unmarshal(raw, &decoded) == nil { + return decoded + } + return "" + } + return value } func OverlayFromJSON(data json.RawMessage) CLIOverlay { diff --git a/pkg/mcptypes/types_coverage_test.go b/pkg/mcptypes/types_coverage_test.go new file mode 100644 index 00000000..b9f9f3af --- /dev/null +++ b/pkg/mcptypes/types_coverage_test.go @@ -0,0 +1,70 @@ +package mcptypes + +import ( + "encoding/json" + "testing" +) + +func TestCLIFlagOverrideUnmarshalJSONCoercesScalarDefaults(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "missing", raw: `{}`, want: ""}, + {name: "null", raw: `{"default":null}`, want: ""}, + {name: "object", raw: `{"default":{"nested":true}}`, want: ""}, + {name: "array", raw: `{"default":[1,2]}`, want: ""}, + {name: "string", raw: `{"default":"hello"}`, want: "hello"}, + {name: "escaped string", raw: `{"default":"line\nvalue"}`, want: "line\nvalue"}, + {name: "boolean", raw: `{"default":true}`, want: "true"}, + {name: "integer", raw: `{"default":42}`, want: "42"}, + {name: "number", raw: `{"default":-1.25}`, want: "-1.25"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got CLIFlagOverride + if err := json.Unmarshal([]byte(tt.raw), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got.Default != tt.want { + t.Fatalf("Default = %q, want %q", got.Default, tt.want) + } + }) + } + + var got CLIFlagOverride + if err := json.Unmarshal([]byte(`{"alias":`), &got); err == nil { + t.Fatal("json.Unmarshal() error = nil, want malformed JSON error") + } + if err := got.UnmarshalJSON([]byte(`{"alias":`)); err == nil { + t.Fatal("CLIFlagOverride.UnmarshalJSON() error = nil, want malformed JSON error") + } + if err := json.Unmarshal([]byte(`{"unknownFlagField":true}`), &got); err == nil { + t.Fatal("CLIFlagOverride.UnmarshalJSON() accepted an unknown field") + } + if got := coercePluginScalar(json.RawMessage(`"unterminated`)); got != "" { + t.Fatalf("coercePluginScalar(invalid string) = %q, want empty", got) + } +} + +func TestOverlayFromJSONHandlesEmptyValidAndMalformedInput(t *testing.T) { + if got := OverlayFromJSON(nil); got.ID != "" || got.Command != "" { + t.Fatalf("OverlayFromJSON(nil) = %#v, want zero overlay", got) + } + + valid := json.RawMessage(`{ + "id":"conference", + "command":"meeting", + "toolOverrides":{"create":{"flags":{"count":{"default":3}}}} + }`) + got := OverlayFromJSON(valid) + if got.ID != "conference" || got.Command != "meeting" || + got.ToolOverrides["create"].Flags["count"].Default != "3" { + t.Fatalf("OverlayFromJSON(valid) = %#v", got) + } + + if got := OverlayFromJSON(json.RawMessage(`{`)); got.ID != "" || got.Command != "" { + t.Fatalf("OverlayFromJSON(malformed) = %#v, want zero overlay", got) + } +} diff --git a/scripts/README.md b/scripts/README.md index 179e022b..e0823cbe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -3,9 +3,11 @@ These repo-local entrypoints are the supported shell entrypoints for building, testing, packaging, and policy checks. - `make build`: build the `dws` CLI from `cmd` -- `make test`: run `go test ./...` -- `make lint`: run formatting checks and required `golangci-lint` -- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/` +- `make test`: run every default package returned by `go list ./...` +- `make test-plan`: verify every default Go package belongs to exactly one CI test shard +- `make lint`: run the repository-wide format check, `go vet`, and required `staticcheck` +- `make format-check`: check every tracked or non-ignored untracked repository Go source file with `gofmt` +- `make fmt`: format every tracked or non-ignored untracked repository Go source file - `make policy`: reuse the current `dws` binary and run the complete policy suite (`make build` first) - `make package`: build all release artifacts locally - `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag) @@ -15,6 +17,7 @@ Script groups: - Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh` - Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh` +- CI package plan: `./scripts/ci/test-packages.sh` - Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh` - Policy checks: `./scripts/policy/check-schema-command-registry.sh`, `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref `, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh` - Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace diff --git a/scripts/ci/test-packages.sh b/scripts/ci/test-packages.sh new file mode 100755 index 00000000..041497d1 --- /dev/null +++ b/scripts/ci/test-packages.sh @@ -0,0 +1,121 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +MODULE="$(cd "$ROOT" && go list -m)" + +usage() { + printf '%s\n' \ + "usage: $0 list " \ + " $0 verify" >&2 + exit 2 +} + +list_shard() { + shard="$1" + cd "$ROOT" + + case "$shard" in + app) + go list ./internal/app/... + ;; + generators) + go list ./internal/generator/... + ;; + helpers) + go list ./internal/helpers/... + ;; + release-scripts) + go list ./test/scripts/... + ;; + remaining) + all_packages="$(go list ./...)" + printf '%s\n' "$all_packages" | while IFS= read -r package; do + case "$package" in + "$MODULE/internal/app"|"$MODULE/internal/app/"*) ;; + "$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;; + "$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;; + "$MODULE/test/scripts"|"$MODULE/test/scripts/"*) ;; + *) printf '%s\n' "$package" ;; + esac + done + ;; + *) + printf 'unknown test package shard: %s\n' "$shard" >&2 + exit 2 + ;; + esac +} + +verify_plan() { + workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")" + trap 'rm -rf "$workdir"' EXIT HUP INT TERM + + expected="$workdir/expected" + assigned="$workdir/assigned" + unique="$workdir/unique" + duplicates="$workdir/duplicates" + missing="$workdir/missing" + unexpected="$workdir/unexpected" + all_packages="$workdir/all-packages" + + cd "$ROOT" + go list ./... > "$all_packages" + LC_ALL=C sort -u "$all_packages" > "$expected" + : > "$assigned" + + for shard in app generators helpers remaining release-scripts; do + shard_packages="$workdir/$shard" + unsorted_shard_packages="$workdir/$shard.unsorted" + list_shard "$shard" > "$unsorted_shard_packages" + LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages" + if [ ! -s "$shard_packages" ]; then + printf 'test package shard is empty: %s\n' "$shard" >&2 + exit 1 + fi + cat "$shard_packages" >> "$assigned" + done + + LC_ALL=C sort "$assigned" -o "$assigned" + uniq -d "$assigned" > "$duplicates" + LC_ALL=C sort -u "$assigned" > "$unique" + comm -23 "$expected" "$unique" > "$missing" + comm -13 "$expected" "$unique" > "$unexpected" + + failed=0 + if [ -s "$duplicates" ]; then + printf '%s\n' 'test packages assigned to more than one shard:' >&2 + sed 's/^/ /' "$duplicates" >&2 + failed=1 + fi + if [ -s "$missing" ]; then + printf '%s\n' 'default Go packages missing from the CI test plan:' >&2 + sed 's/^/ /' "$missing" >&2 + failed=1 + fi + if [ -s "$unexpected" ]; then + printf '%s\n' 'CI test plan contains packages outside go list ./...:' >&2 + sed 's/^/ /' "$unexpected" >&2 + failed=1 + fi + if [ "$failed" -ne 0 ]; then + exit 1 + fi + + package_count="$(wc -l < "$expected" | tr -d ' ')" + printf 'test package plan covers %s default packages exactly once\n' "$package_count" +} + +case "${1:-}" in + list) + [ "$#" -eq 2 ] || usage + list_shard "$2" + ;; + verify) + [ "$#" -eq 1 ] || usage + verify_plan + ;; + *) + usage + ;; +esac diff --git a/scripts/dev/ci-local.sh b/scripts/dev/ci-local.sh index 58282c41..e9da065b 100755 --- a/scripts/dev/ci-local.sh +++ b/scripts/dev/ci-local.sh @@ -11,7 +11,7 @@ run() { cd "$ROOT" run ./scripts/dev/lint.sh -run go test ./... +run make test COVERAGE_PROFILE="$(mktemp "${TMPDIR:-/tmp}/dws-coverage.XXXXXX")" run ./scripts/dev/coverage.sh "$COVERAGE_PROFILE" rm -f "$COVERAGE_PROFILE" diff --git a/scripts/dev/lint.sh b/scripts/dev/lint.sh index 22a191e6..ca6e3a43 100755 --- a/scripts/dev/lint.sh +++ b/scripts/dev/lint.sh @@ -1,13 +1,12 @@ #!/bin/sh set -eu +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$ROOT" + # ── Format check ────────────────────────────────────────── -unformatted="$(find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -l)" -if [ -n "$unformatted" ]; then - echo "$unformatted" - echo "Go files are not formatted. Run 'make fmt'." >&2 - exit 1 -fi +echo "Running gofmt check..." +make format-check # ── go vet (built-in) ──────────────────────────────────── echo "Running go vet..." diff --git a/scripts/dev/run-mock-e2e.sh b/scripts/dev/run-mock-e2e.sh index 4ac42faf..18c83f6a 100755 --- a/scripts/dev/run-mock-e2e.sh +++ b/scripts/dev/run-mock-e2e.sh @@ -4,7 +4,7 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" cd "$ROOT" -go test ./test/mock_mcp/... \ - ./test/integration/discovery/... \ - ./test/integration/extensions/... \ - ./test/integration/recovery/... +DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" \ + go test -count=1 -timeout=5m \ + ./test/mock_mcp/... \ + ./test/integration/... diff --git a/scripts/policy/check-changelog-pr.sh b/scripts/policy/check-changelog-pr.sh new file mode 100755 index 00000000..b31af5cf --- /dev/null +++ b/scripts/policy/check-changelog-pr.sh @@ -0,0 +1,339 @@ +#!/bin/sh +set -eu + +# Validate CHANGELOG.md changes against the release contract. +# +# --fast-path additionally requires the complete PR diff to be exactly one +# in-place CHANGELOG.md modification. --content-only validates CHANGELOG.md +# while allowing other files to change in the same PR. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" + +usage() { + printf '%s\n' "usage: $0 (--fast-path|--content-only) BASE HEAD" >&2 +} + +if [ "$#" -ne 3 ]; then + usage + exit 2 +fi + +case "$1" in +--fast-path) + VALIDATION_MODE="fast-path" + ;; +--content-only) + VALIDATION_MODE="content-only" + ;; +*) + usage + exit 2 + ;; +esac +BASE_REF="$2" +HEAD_REF="$3" + +cd "$ROOT" + +BASE_COMMIT="$(git rev-parse --verify --quiet "${BASE_REF}^{commit}")" || { + printf 'error: CHANGELOG base ref is not an available commit: %s\n' "$BASE_REF" >&2 + exit 2 +} +HEAD_COMMIT="$(git rev-parse --verify --quiet "${HEAD_REF}^{commit}")" || { + printf 'error: CHANGELOG head ref is not an available commit: %s\n' "$HEAD_REF" >&2 + exit 2 +} + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-changelog-pr.XXXXXX")" +trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM + +if ! git merge-base --all "$BASE_COMMIT" "$HEAD_COMMIT" >"$TMP_ROOT/merge-bases"; then + printf 'error: CHANGELOG base and head commits have no merge base\n' >&2 + exit 2 +fi +merge_base_count="$(wc -l <"$TMP_ROOT/merge-bases" | tr -d '[:space:]')" +if [ "$merge_base_count" -ne 1 ]; then + printf 'error: expected exactly one merge base, found %s\n' "$merge_base_count" >&2 + exit 2 +fi +IFS= read -r MERGE_BASE <"$TMP_ROOT/merge-bases" + +require_regular_changelog() { + _rrc_label="$1" + _rrc_commit="$2" + if ! git ls-tree "$_rrc_commit" -- CHANGELOG.md | + awk ' + NR == 1 && + $1 == "100644" && + $2 == "blob" && + $4 == "CHANGELOG.md" { + valid = 1 + } + END { + exit !(NR == 1 && valid) + } + '; then + printf 'error: CHANGELOG.md must be a regular 100644 blob at %s\n' \ + "$_rrc_label" >&2 + exit 1 + fi +} + +# The merge base is the effective base of the PR diff. Also validate the +# caller-provided base commit so a stale or malformed target ref fails closed. +require_regular_changelog "base" "$BASE_COMMIT" +if [ "$MERGE_BASE" != "$BASE_COMMIT" ]; then + require_regular_changelog "merge base" "$MERGE_BASE" +fi +require_regular_changelog "head" "$HEAD_COMMIT" + +git diff --no-ext-diff --find-renames --name-status \ + "$MERGE_BASE" "$HEAD_COMMIT" >"$TMP_ROOT/name-status" +case "$VALIDATION_MODE" in +fast-path) + printf 'M\tCHANGELOG.md\n' >"$TMP_ROOT/expected-name-status" + if ! cmp -s "$TMP_ROOT/expected-name-status" "$TMP_ROOT/name-status"; then + printf '%s\n' 'error: fast path requires exactly one in-place modification: CHANGELOG.md' >&2 + if [ -s "$TMP_ROOT/name-status" ]; then + sed 's/^/ /' "$TMP_ROOT/name-status" >&2 + else + printf '%s\n' ' (no changed files)' >&2 + fi + exit 1 + fi + ;; +content-only) + if ! awk -F ' ' ' + $1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { + count++ + } + END { + exit count != 1 + } + ' "$TMP_ROOT/name-status"; then + printf '%s\n' 'error: content-only validation requires an in-place modification: CHANGELOG.md' >&2 + if [ -s "$TMP_ROOT/name-status" ]; then + sed 's/^/ /' "$TMP_ROOT/name-status" >&2 + else + printf '%s\n' ' (no changed files)' >&2 + fi + exit 1 + fi + ;; +esac + +if ! git diff --no-ext-diff --check "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md; then + printf '%s\n' 'error: CHANGELOG diff contains whitespace errors' >&2 + exit 1 +fi + +"$ROOT/scripts/policy/open-source-audit.sh" + +if ! git show "${MERGE_BASE}:CHANGELOG.md" >"$TMP_ROOT/base-changelog" || + ! git show "${HEAD_COMMIT}:CHANGELOG.md" >"$TMP_ROOT/head-changelog"; then + printf '%s\n' 'error: could not read CHANGELOG.md from both commits' >&2 + exit 2 +fi + +git diff --no-ext-diff --unified=0 \ + "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md >"$TMP_ROOT/changelog.patch" +if grep -q '^Binary files ' "$TMP_ROOT/changelog.patch"; then + printf '%s\n' 'error: CHANGELOG.md must remain a text file' >&2 + exit 1 +fi +if ! awk ' + /^\+\+\+ / { next } + /^\+/ { + line = substr($0, 2) + if (line ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) { + bad = 1 + } + } + END { exit bad } +' "$TMP_ROOT/changelog.patch"; then + printf '%s\n' 'error: changed CHANGELOG additions must not contain TODO/TBD placeholders' >&2 + exit 1 +fi + +. "$ROOT/scripts/release/release-lib.sh" + +extract_labeled_section() { + _els_file="$1" + _els_wanted="$2" + awk -v wanted="$_els_wanted" ' + /^## / { + active = 0 + if (substr($0, 1, 4) == "## [") { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) { + active = 1 + } + } + } + active { print } + ' "$_els_file" +} + +extract_unmanaged_content() { + _euc_file="$1" + awk ' + BEGIN { active = 1 } + /^## / { + active = 1 + if (substr($0, 1, 4) == "## [") { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1) { + active = 0 + } + } + } + active { print } + ' "$_euc_file" +} + +label_heading_count() { + _lhc_file="$1" + _lhc_wanted="$2" + awk -v wanted="$_lhc_wanted" ' + /^## / && substr($0, 1, 4) == "## [" { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) count++ + } + END { print count + 0 } + ' "$_lhc_file" +} + +calendar_date_is_valid() { + _cdiv_date="$1" + awk -v date="$_cdiv_date" 'BEGIN { + if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) exit 1 + split(date, part, "-") + year = part[1] + 0 + month = part[2] + 0 + day = part[3] + 0 + if (year < 1 || month < 1 || month > 12 || day < 1) exit 1 + days = 31 + if (month == 4 || month == 6 || month == 9 || month == 11) days = 30 + if (month == 2) { + days = 28 + if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) days = 29 + } + exit day > days + }' +} + +release_heading_date() { + _rhd_file="$1" + _rhd_version="$2" + awk -v wanted="$_rhd_version" ' + BEGIN { prefix = "## [" wanted "] - " } + index($0, prefix) == 1 { + print substr($0, length(prefix) + 1) + } + ' "$_rhd_file" +} + +validate_release_notes() { + _vrn_file="$1" + set +e + awk ' + NR == 1 { next } + { + if ($0 ~ /[^[:space:]]/) meaningful = 1 + if ($0 ~ /^- /) bullet = 1 + if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1 + } + END { + if (!meaningful || !bullet) exit 43 + if (placeholder) exit 44 + } + ' "$_vrn_file" + _vrn_status=$? + set -e + case "$_vrn_status" in + 0) return 0 ;; + 43) printf '%s\n' 'error: changed release section must contain notes and at least one bullet' >&2 ;; + 44) printf '%s\n' 'error: changed release section still contains TODO/TBD placeholders' >&2 ;; + *) printf '%s\n' 'error: failed to validate changed release notes' >&2 ;; + esac + return "$_vrn_status" +} + +extract_unmanaged_content "$TMP_ROOT/base-changelog" >"$TMP_ROOT/base-unmanaged" +extract_unmanaged_content "$TMP_ROOT/head-changelog" >"$TMP_ROOT/head-unmanaged" +if ! cmp -s "$TMP_ROOT/base-unmanaged" "$TMP_ROOT/head-unmanaged"; then + printf '%s\n' 'error: CHANGELOG validation only permits notes inside Unreleased or versioned release sections' >&2 + exit 1 +fi + +unreleased_count="$(label_heading_count "$TMP_ROOT/head-changelog" Unreleased)" +unreleased_exact_count="$(awk '$0 == "## [Unreleased]" { count++ } END { print count + 0 }' \ + "$TMP_ROOT/head-changelog")" +if [ "$unreleased_count" -ne 1 ] || [ "$unreleased_exact_count" -ne 1 ]; then + printf 'error: CHANGELOG must contain exactly one heading: ## [Unreleased]\n' >&2 + exit 1 +fi + +extract_labeled_section "$TMP_ROOT/base-changelog" Unreleased >"$TMP_ROOT/base-unreleased" +extract_labeled_section "$TMP_ROOT/head-changelog" Unreleased >"$TMP_ROOT/head-unreleased" +unreleased_changed=0 +if ! cmp -s "$TMP_ROOT/base-unreleased" "$TMP_ROOT/head-unreleased"; then + unreleased_changed=1 +fi + +awk ' + /^## / && substr($0, 1, 4) == "## [" { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1) { + label = substr(rest, 1, close_pos - 1) + if (label != "Unreleased") print label + } + } +' "$TMP_ROOT/base-changelog" "$TMP_ROOT/head-changelog" | + LC_ALL=C sort -u >"$TMP_ROOT/release-labels" + +changed_release_count=0 +section_index=0 +while IFS= read -r version; do + [ -n "$version" ] || continue + section_index=$((section_index + 1)) + base_section="$TMP_ROOT/base-release-$section_index" + head_section="$TMP_ROOT/head-release-$section_index" + extract_labeled_section "$TMP_ROOT/base-changelog" "$version" >"$base_section" + extract_labeled_section "$TMP_ROOT/head-changelog" "$version" >"$head_section" + if cmp -s "$base_section" "$head_section"; then + continue + fi + + changed_release_count=$((changed_release_count + 1)) + if ! release_channel_for_version "v$version" >/dev/null 2>&1; then + printf 'error: changed CHANGELOG release heading has an invalid version: %s\n' "$version" >&2 + exit 1 + fi + if [ "$(label_heading_count "$TMP_ROOT/head-changelog" "$version")" -ne 1 ]; then + printf 'error: CHANGELOG must contain exactly one well-formed section for %s\n' \ + "$version" >&2 + exit 1 + fi + if ! validate_release_notes "$head_section"; then + exit 1 + fi + release_date="$(release_heading_date "$TMP_ROOT/head-changelog" "$version")" + if ! calendar_date_is_valid "$release_date"; then + printf 'error: CHANGELOG section for %s has an invalid calendar date: %s\n' \ + "$version" "$release_date" >&2 + exit 1 + fi +done <"$TMP_ROOT/release-labels" + +if [ "$changed_release_count" -eq 0 ] && [ "$unreleased_changed" -eq 0 ]; then + printf '%s\n' 'error: CHANGELOG modification did not change an eligible notes section' >&2 + exit 1 +fi + +printf 'CHANGELOG PR check: ok (mode=%s release_sections=%s unreleased_changed=%s)\n' \ + "$VALIDATION_MODE" "$changed_release_count" "$unreleased_changed" diff --git a/scripts/policy/check-coverage-gate.sh b/scripts/policy/check-coverage-gate.sh index 4b1d7c8d..2db179f8 100755 --- a/scripts/policy/check-coverage-gate.sh +++ b/scripts/policy/check-coverage-gate.sh @@ -4,17 +4,17 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" BASE_REF="" OVERALL_PROFILE="coverage.txt" -ADDITIONAL_PROFILE="${COVERAGE_ADDITIONAL_PROFILE:-}" +ADDITIONAL_DIFF_PROFILE="${COVERAGE_ADDITIONAL_DIFF_PROFILE:-${COVERAGE_ADDITIONAL_PROFILE:-}}" BASELINE_PROFILE="coverage-base.txt" DIFF_PROFILE="coverage-policy.txt" -TARGET="${COVERAGE_TARGET:-80}" -OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0.1}" +TARGET="${COVERAGE_TARGET:-100}" +OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0}" ENFORCE_OVERALL="${COVERAGE_ENFORCE_OVERALL:-false}" CHANGED_ONLY="false" SCOPE_BUILDABLE="false" usage() { - printf '%s\n' "usage: $0 --base-ref [--changed-only] [--scope-buildable] [--overall-profile ] [--additional-profile ] [--baseline-profile ] [--diff-profile ]" >&2 + printf '%s\n' "usage: $0 --base-ref [--changed-only] [--scope-buildable] [--overall-profile ] [--additional-diff-profile ] [--baseline-profile ] [--diff-profile ]" >&2 } while [ "$#" -gt 0 ]; do @@ -29,9 +29,9 @@ while [ "$#" -gt 0 ]; do OVERALL_PROFILE="$2" shift 2 ;; - --additional-profile) + --additional-diff-profile|--additional-profile) [ "$#" -ge 2 ] || { usage; exit 2; } - ADDITIONAL_PROFILE="$2" + ADDITIONAL_DIFF_PROFILE="$2" shift 2 ;; --baseline-profile) @@ -87,19 +87,12 @@ set -- "$CHECKER" \ if [ "$CHANGED_ONLY" = "true" ]; then set -- "$@" --changed-only else - baseline="$(go tool cover -func="$BASELINE_PROFILE" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" - [ -n "$baseline" ] || { - printf 'error: cannot parse authoritative coverage from %s\n' "$BASELINE_PROFILE" >&2 - exit 2 - } set -- "$@" \ --overall-profile "$OVERALL_PROFILE" \ --diff-profile "$OVERALL_PROFILE" \ - --baseline-overall "$baseline" - if [ -n "$ADDITIONAL_PROFILE" ]; then - set -- "$@" \ - --overall-profile "$ADDITIONAL_PROFILE" \ - --diff-profile "$ADDITIONAL_PROFILE" + --baseline-profile "$BASELINE_PROFILE" + if [ -n "$ADDITIONAL_DIFF_PROFILE" ]; then + set -- "$@" --diff-profile "$ADDITIONAL_DIFF_PROFILE" fi fi if [ "$SCOPE_BUILDABLE" = "true" ]; then diff --git a/scripts/policy/coverage-gate/main.go b/scripts/policy/coverage-gate/main.go index 83f6b1ea..c0da205e 100644 --- a/scripts/policy/coverage-gate/main.go +++ b/scripts/policy/coverage-gate/main.go @@ -13,7 +13,6 @@ import ( "flag" "fmt" "io" - "math" "os" "os/exec" "path/filepath" @@ -37,11 +36,13 @@ func (values *stringList) Set(value string) error { } type coverageBlock struct { - File string - StartLine int - EndLine int - Statements int - Count int + File string + StartLine int + StartColumn int + EndLine int + EndColumn int + Statements int + Count int } type lineRange struct { @@ -78,6 +79,7 @@ func run( buildableLoader func() (map[string]bool, error), ) int { var overallPaths stringList + var baselinePaths stringList var diffPaths stringList var baseRef string var modulePath string @@ -90,12 +92,13 @@ func run( flags := flag.NewFlagSet("coverage-gate", flag.ContinueOnError) flags.SetOutput(stderr) flags.Var(&overallPaths, "overall-profile", "coverage profile used for overall coverage (repeatable)") + flags.Var(&baselinePaths, "baseline-profile", "merge-base coverage profile evaluated with the same model as the candidate (repeatable)") flags.Var(&diffPaths, "diff-profile", "coverage profile used for changed-code coverage (repeatable)") flags.StringVar(&baseRef, "base-ref", "", "Git merge-base or previous main SHA") flags.StringVar(&modulePath, "module", "", "Go module path used to normalize profile filenames") flags.Float64Var(&baselineOverall, "baseline-overall", -1, "authoritative overall coverage percentage") - flags.Float64Var(&overallTolerance, "overall-tolerance", 0.1, "allowed overall coverage measurement variance in percentage points") - flags.Float64Var(&target, "target", 80, "required changed-code and eventual overall coverage percentage") + flags.Float64Var(&overallTolerance, "overall-tolerance", 0, "allowed overall coverage measurement variance in percentage points") + flags.Float64Var(&target, "target", 100, "required changed-code coverage percentage and optional overall floor") flags.BoolVar(&enforceOverall, "enforce-overall-target", false, "require overall coverage to reach target") flags.BoolVar(&changedOnly, "changed-only", false, "enforce changed-code coverage without an overall baseline") flags.BoolVar(&scopeBuildable, "scope-buildable", false, "only evaluate changed files buildable on the current platform") @@ -103,8 +106,13 @@ func run( return 2 } - if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || (!changedOnly && (len(overallPaths) == 0 || baselineOverall < 0)) { - fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and --baseline-overall") + if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || + (!changedOnly && (len(overallPaths) == 0 || (len(baselinePaths) == 0 && baselineOverall < 0))) { + fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and either --baseline-profile or --baseline-overall") + return 2 + } + if len(baselinePaths) > 0 && baselineOverall >= 0 { + fmt.Fprintln(stderr, "coverage-gate accepts either --baseline-profile or --baseline-overall, not both") return 2 } var overall []coverageBlock @@ -115,6 +123,14 @@ func run( fmt.Fprintln(stderr, err) return 2 } + if len(baselinePaths) > 0 { + baseline, baselineErr := readProfiles(baselinePaths, modulePath) + if baselineErr != nil { + fmt.Fprintln(stderr, baselineErr) + return 2 + } + baselineOverall = coveragePercent(baseline) + } } diff, err := readProfiles(diffPaths, modulePath) if err != nil { @@ -150,12 +166,12 @@ func run( if enforceOverall { mode = "required" } - fmt.Fprintf(stdout, "overall coverage: %.1f%% (merge-base %.1f%%; tolerance %.1fpp; target %.1f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode) + fmt.Fprintf(stdout, "overall coverage: %.4f%% (merge-base %.4f%%; tolerance %.4fpp; target %.4f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode) } if result.ChangedStatements == 0 { - fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.1f%%)\n", target) + fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.4f%%)\n", target) } else { - fmt.Fprintf(stdout, "changed code coverage: %.1f%% (%d executable statements; target %.1f%%)\n", result.ChangedCoverage, result.ChangedStatements, target) + fmt.Fprintf(stdout, "changed code coverage: %.4f%% (%d executable statements; target %.4f%%)\n", result.ChangedCoverage, result.ChangedStatements, target) } if len(result.Failures) > 0 { fmt.Fprintln(stderr, "coverage gate failed:") @@ -171,13 +187,11 @@ func evaluate(input gateInput) gateResult { result := gateResult{Failures: []string{}} if !input.ChangedOnly { result.Overall = coveragePercent(input.Overall) - baselineRounded := roundOne(input.BaselineOverall) - overallRounded := roundOne(result.Overall) - if overallRounded+input.OverallTolerance+1e-9 < baselineRounded { - result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.1f%% to %.1f%%", baselineRounded, overallRounded)) + if result.Overall+input.OverallTolerance < input.BaselineOverall { + result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.4f%% to %.4f%%", input.BaselineOverall, result.Overall)) } - if input.EnforceOverall && overallRounded < input.Target { - result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.1f%% is below target %.1f%%", overallRounded, input.Target)) + if input.EnforceOverall && result.Overall < input.Target { + result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.4f%% is below target %.4f%%", result.Overall, input.Target)) } } @@ -206,8 +220,8 @@ func evaluate(input gateInput) gateResult { result.ChangedStatements = total if total > 0 { result.ChangedCoverage = float64(covered) * 100 / float64(total) - if result.ChangedCoverage+1e-9 < input.Target { - result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.1f%% is below target %.1f%%", result.ChangedCoverage, input.Target)) + if result.ChangedCoverage < input.Target { + result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.4f%% is below target %.4f%%", result.ChangedCoverage, input.Target)) } } return result @@ -253,11 +267,13 @@ func readProfiles(paths []string, modulePath string) ([]coverageBlock, error) { values = append(values, value) } blocks = append(blocks, coverageBlock{ - File: normalizeProfilePath(match[1], modulePath), - StartLine: values[0], - EndLine: values[2], - Statements: values[4], - Count: values[5], + File: normalizeProfilePath(match[1], modulePath), + StartLine: values[0], + StartColumn: values[1], + EndLine: values[2], + EndColumn: values[3], + Statements: values[4], + Count: values[5], }) } err = scanner.Err() @@ -402,7 +418,15 @@ func coveragePercent(blocks []coverageBlock) float64 { func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock { merged := make(map[string]coverageBlock, len(blocks)) for _, block := range blocks { - key := fmt.Sprintf("%s:%d:%d:%d", block.File, block.StartLine, block.EndLine, block.Statements) + key := fmt.Sprintf( + "%s:%d:%d:%d:%d:%d", + block.File, + block.StartLine, + block.StartColumn, + block.EndLine, + block.EndColumn, + block.Statements, + ) current, ok := merged[key] if !ok || block.Count > current.Count { merged[key] = block @@ -419,5 +443,3 @@ func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock { } return result } - -func roundOne(value float64) float64 { return math.Round(value*10) / 10 } diff --git a/scripts/policy/coverage-gate/main_test.go b/scripts/policy/coverage-gate/main_test.go index 11f1770e..b12982ed 100644 --- a/scripts/policy/coverage-gate/main_test.go +++ b/scripts/policy/coverage-gate/main_test.go @@ -39,7 +39,7 @@ func TestRun(t *testing.T) { if code := run(args, &stdout, &stderr, loader, nil); code != 0 { t.Fatalf("run code=%d stderr=%s", code, stderr.String()) } - if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + if !strings.Contains(stdout.String(), "overall coverage: 100.0000%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") { t.Fatalf("unexpected output %q", stdout.String()) } @@ -54,6 +54,115 @@ func TestRun(t *testing.T) { } } +func TestRunDefaultsToFullChangedCodeCoverage(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + partialBody := "mode: atomic\n" + + "example.com/project/internal/a.go:10.1,12.2 9 1\n" + + "example.com/project/internal/a.go:13.1,13.2 1 0\n" + if err := os.WriteFile(profile, []byte(partialBody), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--changed-only", + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{"internal/a.go": {{Start: 10, End: 13}}}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 1 { + t.Fatalf("run code=%d, want 1; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "changed code coverage 90.0000% is below target 100.0000%") { + t.Fatalf("default target was not enforced: %q", stderr.String()) + } + + fullBody := "mode: atomic\n" + + "example.com/project/internal/a.go:10.1,12.2 9 1\n" + + "example.com/project/internal/a.go:13.1,13.2 1 1\n" + if err := os.WriteFile(profile, []byte(fullBody), 0o600); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + if code := run(args, &stdout, &stderr, loader, nil); code != 0 { + t.Fatalf("100%% changed coverage code=%d; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } +} + +func TestRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + body := "mode: atomic\n" + + "example.com/project/internal/a.go:10.1,12.2 5 0\n" + + "example.com/project/internal/a.go:10.1,12.2 5 1\n" + + "example.com/project/internal/a.go:20.1,22.2 5 0\n" + if err := os.WriteFile(profile, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--overall-profile", profile, + "--baseline-profile", profile, + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 0 { + t.Fatalf("run code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "overall coverage: 50.0000% (merge-base 50.0000%") { + t.Fatalf("baseline and candidate did not share one coverage model: %q", stdout.String()) + } +} + +func TestRunFailsClosedWhenBaselineProfileIsMissing(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n" + if err := os.WriteFile(profile, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--overall-profile", profile, + "--baseline-profile", filepath.Join(t.TempDir(), "missing.out"), + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 2 { + t.Fatalf("missing baseline profile code=%d, want 2; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "open coverage profile") { + t.Fatalf("missing baseline profile did not fail closed: %q", stderr.String()) + } +} + +func TestRunRejectsConflictingBaselineSources(t *testing.T) { + args := []string{ + "--overall-profile", "candidate.out", + "--baseline-profile", "baseline.out", + "--baseline-overall", "100", + "--diff-profile", "candidate.out", + "--base-ref", "base", + "--module", "example.com/project", + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, nil, nil); code != 2 { + t.Fatalf("conflicting baseline sources code=%d, want 2; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "either --baseline-profile or --baseline-overall, not both") { + t.Fatalf("conflicting baseline sources were not rejected clearly: %q", stderr.String()) + } +} + func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) { dir := t.TempDir() uncovered := filepath.Join(dir, "uncovered.out") @@ -81,7 +190,7 @@ func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) { if code := run(args, &stdout, &stderr, loader, nil); code != 0 { t.Fatalf("run code=%d stderr=%s", code, stderr.String()) } - if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + if !strings.Contains(stdout.String(), "overall coverage: 100.0000%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") { t.Fatalf("repeated profiles were not unioned: %q", stdout.String()) } } @@ -113,7 +222,7 @@ func TestRunChangedOnlyWithBuildableScope(t *testing.T) { if code := run(args, &stdout, &stderr, loader, buildableLoader); code != 0 { t.Fatalf("run code=%d stderr=%s", code, stderr.String()) } - if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") { t.Fatalf("unexpected changed-only output %q", stdout.String()) } @@ -143,7 +252,11 @@ func TestReadProfiles(t *testing.T) { if err != nil { t.Fatal(err) } - if len(blocks) != 1 || blocks[0].File != "internal/a.go" || blocks[0].Statements != 3 { + if len(blocks) != 1 || + blocks[0].File != "internal/a.go" || + blocks[0].StartColumn != 1 || + blocks[0].EndColumn != 2 || + blocks[0].Statements != 3 { t.Fatalf("blocks=%v", blocks) } if _, err := readProfiles([]string{filepath.Join(t.TempDir(), "missing")}, "example.com/project"); err == nil { @@ -275,6 +388,41 @@ func TestCrossPlatformCoverageUnionsDuplicateCrossPackageCoverageBlocks(t *testi } } +func TestCoverageKeepsDistinctBlocksOnTheSameLine(t *testing.T) { + result := evaluate(gateInput{ + Diff: []coverageBlock{ + { + File: "internal/a.go", + StartLine: 10, + StartColumn: 1, + EndLine: 10, + EndColumn: 5, + Statements: 1, + Count: 1, + }, + { + File: "internal/a.go", + StartLine: 10, + StartColumn: 6, + EndLine: 10, + EndColumn: 10, + Statements: 1, + Count: 0, + }, + }, + Changed: map[string][]lineRange{"internal/a.go": {{Start: 10, End: 10}}}, + Target: 100, + ChangedOnly: true, + }) + if result.ChangedCoverage != 50 || result.ChangedStatements != 2 { + t.Fatalf("same-line blocks were collapsed: %#v", result) + } + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "changed code coverage 50.0000% is below target 100.0000%") { + t.Fatalf("same-line uncovered block did not fail the gate: %v", result.Failures) + } +} + func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { result := evaluate(gateInput{ Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}}, @@ -288,6 +436,52 @@ func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { } } +func TestEvaluateDefaultsToZeroOverallTolerance(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}}, + Changed: map[string][]lineRange{}, + BaselineOverall: 40.4, + OverallTolerance: 0, + Target: 100, + }) + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "overall coverage regressed from 40.4000% to 40.3000%") { + t.Fatalf("zero-tolerance regression failures = %v", result.Failures) + } +} + +func TestEvaluateRejectsRegressionHiddenByOneDecimalRounding(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{ + {Statements: 1999, Count: 1}, + {Statements: 1, Count: 0}, + }, + Changed: map[string][]lineRange{}, + BaselineOverall: 100, + Target: 100, + }) + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "overall coverage regressed from 100.0000% to 99.9500%") { + t.Fatalf("sub-tenth regression failures = %v", result.Failures) + } +} + +func TestEvaluateRejectsChangedCoverageBelow100WithoutRounding(t *testing.T) { + result := evaluate(gateInput{ + Diff: []coverageBlock{ + {File: "internal/a.go", StartLine: 1, EndLine: 1999, Statements: 1999, Count: 1}, + {File: "internal/a.go", StartLine: 2000, EndLine: 2000, Statements: 1, Count: 0}, + }, + Changed: map[string][]lineRange{"internal/a.go": {{Start: 1, End: 2000}}}, + Target: 100, + ChangedOnly: true, + }) + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "changed code coverage 99.9500% is below target 100.0000%") { + t.Fatalf("sub-100 changed-code failures = %v", result.Failures) + } +} + func TestEvaluateFailsClosed(t *testing.T) { result := evaluate(gateInput{ Overall: []coverageBlock{ diff --git a/scripts/release/download-github-release-assets.sh b/scripts/release/download-github-release-assets.sh new file mode 100755 index 00000000..4f00789d --- /dev/null +++ b/scripts/release/download-github-release-assets.sh @@ -0,0 +1,87 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +TAG="${1:-}" +DEST_DIR="${2:-}" +REPOSITORY="${GITHUB_REPOSITORY:-}" +RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}" + +[ -n "$TAG" ] && [ -n "$DEST_DIR" ] && [ -n "$REPOSITORY" ] || { + printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] download-github-release-assets.sh \n' >&2 + exit 2 +} +[ -n "$RELEASE_ID" ] || { + printf 'DWS_GITHUB_RELEASE_ID is required for an exact release download\n' >&2 + exit 2 +} +printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2 + exit 2 +} +command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; } + +DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \ + "$SCRIPT_DIR/verify-github-release-assets.sh" "$TAG" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-release-download.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +tab="$(printf '\t')" +cat > "$tmp/expected" <<'EOF' +checksums.txt +dws-darwin-amd64.tar.gz +dws-darwin-arm64.tar.gz +dws-linux-amd64.tar.gz +dws-linux-arm64.tar.gz +dws-skills.zip +dws-windows-amd64.zip +dws-windows-arm64.zip +EOF +LC_ALL=C sort "$tmp/expected" -o "$tmp/expected" +gh api -H 'Accept: application/vnd.github+json' \ + "repos/$REPOSITORY/releases/$RELEASE_ID" \ + --jq '.assets[] | [.id, .name] | @tsv' > "$tmp/assets.tsv" +cut -f 2 "$tmp/assets.tsv" | LC_ALL=C sort > "$tmp/actual" +if ! diff -u "$tmp/expected" "$tmp/actual"; then + printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2 + exit 1 +fi + +while IFS="$tab" read -r asset_id asset_name; do + printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid GitHub Release asset ID: %s\n' "$asset_id" >&2 + exit 1 + } + case "$asset_name" in + checksums.txt | \ + dws-darwin-amd64.tar.gz | dws-darwin-arm64.tar.gz | \ + dws-linux-amd64.tar.gz | dws-linux-arm64.tar.gz | \ + dws-windows-amd64.zip | dws-windows-arm64.zip | \ + dws-skills.zip) ;; + *) + printf 'unsupported GitHub Release asset name: %s\n' "$asset_name" >&2 + exit 1 + ;; + esac + gh api -H 'Accept: application/octet-stream' \ + "repos/$REPOSITORY/releases/assets/$asset_id" > "$tmp/$asset_name" +done < "$tmp/assets.tsv" + +mkdir -p "$DEST_DIR" +for asset_name in \ + checksums.txt \ + dws-darwin-amd64.tar.gz \ + dws-darwin-arm64.tar.gz \ + dws-linux-amd64.tar.gz \ + dws-linux-arm64.tar.gz \ + dws-windows-amd64.zip \ + dws-windows-arm64.zip \ + dws-skills.zip; do + [ -f "$tmp/$asset_name" ] || { + printf 'GitHub Release asset was not downloaded: %s\n' "$asset_name" >&2 + exit 1 + } + mv -f "$tmp/$asset_name" "$DEST_DIR/$asset_name" +done + +printf 'GitHub Release assets downloaded and verified: %s\n' "$TAG" diff --git a/scripts/release/dws-release.sh b/scripts/release/dws-release.sh index c9b205f4..c064dea1 100755 --- a/scripts/release/dws-release.sh +++ b/scripts/release/dws-release.sh @@ -128,13 +128,8 @@ require_remote() { } sync_main_if_safe() { - current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" - [ "$current_branch" = "main" ] || { - printf 'release validation must run from the main worktree (current: %s)\n' "${current_branch:-detached HEAD}" >&2 - exit 1 - } [ -z "$(git status --porcelain --untracked-files=all)" ] || { - printf '%s\n' 'release main worktree must be clean before synchronization' >&2 + printf '%s\n' 'release worktree must be clean before synchronization' >&2 exit 1 } @@ -146,6 +141,14 @@ sync_main_if_safe() { if [ "$head_commit" = "$remote_commit" ]; then return 0 fi + current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + if [ "$current_branch" != "main" ]; then + if git merge-base --is-ancestor HEAD "$remote_main"; then + return 0 + fi + printf 'HEAD is not contained in %s/main history; merge it through a reviewed PR before release\n' "$REMOTE" >&2 + exit 1 + fi if git merge-base --is-ancestor HEAD "$remote_main"; then git merge --ff-only "$remote_main" return 0 diff --git a/scripts/release/next-release-version.sh b/scripts/release/next-release-version.sh new file mode 100755 index 00000000..e0d4a6d7 --- /dev/null +++ b/scripts/release/next-release-version.sh @@ -0,0 +1,203 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" + +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" +CHANNEL="" +REQUESTED_CHANNEL="" +BUMP="patch" + +usage() { + cat >&2 <<'EOF' +usage: next-release-version.sh --channel [options] + +Options: + --bump Core bump when starting a new beta line (default: patch) + --repo-root Override repository root (primarily for tests) + +Output: + release_version= + from_beta= + channel= + base= + +Both ordinary release tags and refs/tags/withdrawn/v... tombstones reserve a +version permanently. The caller must create the returned release tag +atomically; this script only calculates the next candidate. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --channel) + [ "$#" -ge 2 ] || { usage; exit 2; } + REQUESTED_CHANNEL="$2" + shift 2 + ;; + --bump) + [ "$#" -ge 2 ] || { usage; exit 2; } + BUMP="$2" + shift 2 + ;; + --repo-root) + [ "$#" -ge 2 ] || { usage; exit 2; } + ROOT="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +case "$REQUESTED_CHANNEL" in + beta|prerelease) CHANNEL="prerelease" ;; + stable) CHANNEL="stable" ;; + *) + printf 'invalid release channel: %s (expected beta, prerelease, or stable)\n' "${REQUESTED_CHANNEL:-}" >&2 + exit 2 + ;; +esac +case "$BUMP" in + patch|minor|major) ;; + *) + printf 'invalid version bump: %s (expected patch, minor, or major)\n' "$BUMP" >&2 + exit 2 + ;; +esac + +cd "$ROOT" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + printf 'not a Git worktree: %s\n' "$ROOT" >&2 + exit 1 +} + +allocated_version_for_ref() { + _avfr_ref="$1" + case "$_avfr_ref" in + refs/tags/withdrawn/v*) printf '%s\n' "${_avfr_ref#refs/tags/withdrawn/}" ;; + refs/tags/v*) printf '%s\n' "${_avfr_ref#refs/tags/}" ;; + *) return 1 ;; + esac +} + +stable_core_is_allocated() { + _scia_core="$1" + git show-ref --verify --quiet "refs/tags/$_scia_core" || + git show-ref --verify --quiet "refs/tags/withdrawn/$_scia_core" +} + +version_is_withdrawn() { + git show-ref --verify --quiet "refs/tags/withdrawn/$1" +} + +bump_core() { + _bc_version="${1#v}" + _bc_major="${_bc_version%%.*}" + _bc_remainder="${_bc_version#*.}" + _bc_minor="${_bc_remainder%%.*}" + _bc_patch="${_bc_remainder#*.}" + case "$2" in + patch) _bc_patch=$((_bc_patch + 1)) ;; + minor) + _bc_minor=$((_bc_minor + 1)) + _bc_patch=0 + ;; + major) + _bc_major=$((_bc_major + 1)) + _bc_minor=0 + _bc_patch=0 + ;; + esac + printf 'v%s.%s.%s\n' "$_bc_major" "$_bc_minor" "$_bc_patch" +} + +tag_refs="$(git for-each-ref --format='%(refname)' refs/tags)" +latest_stable="" +for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + if release_is_stable_version "$version"; then + if [ -z "$latest_stable" ] || release_core_is_greater "$version" "$latest_stable"; then + latest_stable="$version" + fi + fi +done + +highest_open_beta_core="" +for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + core="$(release_core_tag "$version")" + stable_core_is_allocated "$core" && continue + if [ -n "$latest_stable" ] && ! release_core_is_greater "$core" "$latest_stable"; then + continue + fi + if [ -z "$highest_open_beta_core" ] || release_core_is_greater "$core" "$highest_open_beta_core"; then + highest_open_beta_core="$core" + fi +done + +release_version="" +from_beta="" +base="$latest_stable" + +if [ "$CHANNEL" = "prerelease" ]; then + if [ -n "$highest_open_beta_core" ]; then + latest_beta="" + for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + [ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue + if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then + latest_beta="$version" + fi + done + next_beta_number=$(( $(release_beta_number "$latest_beta") + 1 )) + release_version="$highest_open_beta_core-beta.$next_beta_number" + else + [ -n "$latest_stable" ] || { + printf 'cannot start a beta line without an allocated stable baseline\n' >&2 + exit 1 + } + next_core="$(bump_core "$latest_stable" "$BUMP")" + release_version="$next_core-beta.1" + fi +else + [ -n "$highest_open_beta_core" ] || { + printf 'cannot create a stable release without an open beta line newer than the latest allocated stable\n' >&2 + exit 1 + } + latest_beta="" + for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + [ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue + if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then + latest_beta="$version" + fi + done + if version_is_withdrawn "$latest_beta" || + ! git show-ref --verify --quiet "refs/tags/$latest_beta"; then + printf 'latest beta %s is withdrawn; create the next beta before stable promotion\n' "$latest_beta" >&2 + exit 1 + fi + release_version="$highest_open_beta_core" + from_beta="$latest_beta" +fi + +printf 'release_version=%s\n' "$release_version" +printf 'from_beta=%s\n' "$from_beta" +printf 'channel=%s\n' "$CHANNEL" +printf 'base=%s\n' "$base" diff --git a/scripts/release/reconcile-gitee-assets.sh b/scripts/release/reconcile-gitee-assets.sh index 5bf443b6..50ce4ace 100755 --- a/scripts/release/reconcile-gitee-assets.sh +++ b/scripts/release/reconcile-gitee-assets.sh @@ -8,16 +8,32 @@ GITEE_API="${GITEE_API:-https://gitee.com/api/v5}" GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}" GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}" GITEE_LIST_MAX_TIME="${GITEE_LIST_MAX_TIME:-20}" +# Attachment-list reads are safe to retry and are used both before an upload +# and to detect a committed upload whose HTTP response was lost. Keep retrying +# long enough to bridge a short Gitee TLS/API outage without ever blindly +# replaying the upload itself. +GITEE_LIST_RETRIES="${GITEE_LIST_RETRIES:-24}" +GITEE_LIST_RETRY_DELAY="${GITEE_LIST_RETRY_DELAY:-20}" +GITEE_LIST_RETRY_WINDOW_SECONDS="${GITEE_LIST_RETRY_WINDOW_SECONDS:-420}" GITEE_VERIFY_MAX_TIME="${GITEE_VERIFY_MAX_TIME:-60}" GITEE_MUTATION_MAX_TIME="${GITEE_MUTATION_MAX_TIME:-20}" -GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-120}" +# Gitee does not return a response until an attachment upload is committed. +# From GitHub-hosted runners, a near-10 MiB DWS binary can legitimately take +# more than five minutes, so keep the transfer deadline above that observed +# floor while the per-asset and overall deadlines retain hard upper bounds. +GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-1200}" +# The per-asset deadline guarantees one complete slow upload plus one complete +# attachment-list recovery on each side of that upload. The second upload +# attempt is allowed only for zero-byte failures before HTTP begins; a first +# attempt that consumes the full transfer deadline intentionally exhausts the +# retry budget instead of holding a runner for another full slow upload. GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-2}" GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-5}" GITEE_EXISTING_VERIFY_ATTEMPTS="${GITEE_EXISTING_VERIFY_ATTEMPTS:-1}" GITEE_POST_UPLOAD_VERIFY_ATTEMPTS="${GITEE_POST_UPLOAD_VERIFY_ATTEMPTS:-2}" GITEE_VERIFY_RETRY_DELAY="${GITEE_VERIFY_RETRY_DELAY:-5}" -GITEE_ASSET_TIMEOUT_SECONDS="${GITEE_ASSET_TIMEOUT_SECONDS:-600}" -GITEE_OVERALL_TIMEOUT_SECONDS="${GITEE_OVERALL_TIMEOUT_SECONDS:-4920}" +GITEE_ASSET_TIMEOUT_SECONDS="${GITEE_ASSET_TIMEOUT_SECONDS:-2220}" +GITEE_OVERALL_TIMEOUT_SECONDS="${GITEE_OVERALL_TIMEOUT_SECONDS:-18300}" err() { printf 'error: %s\n' "$*" >&2 @@ -43,6 +59,8 @@ for setting in \ GITEE_CURL_CONNECT_TIMEOUT \ GITEE_CURL_MAX_TIME \ GITEE_LIST_MAX_TIME \ + GITEE_LIST_RETRIES \ + GITEE_LIST_RETRY_WINDOW_SECONDS \ GITEE_VERIFY_MAX_TIME \ GITEE_MUTATION_MAX_TIME \ GITEE_UPLOAD_MAX_TIME \ @@ -54,6 +72,7 @@ for setting in \ require_positive_integer "$setting" "${!setting}" done for setting in \ + GITEE_LIST_RETRY_DELAY \ GITEE_UPLOAD_RETRY_DELAY \ GITEE_VERIFY_RETRY_DELAY; do require_nonnegative_integer "$setting" "${!setting}" @@ -139,21 +158,81 @@ api_get() { --max-time "$max_time" "$@" } -list_assets() { - api_get "$GITEE_LIST_MAX_TIME" \ +list_assets_once() { + local max_time="$1" payload + payload="$(api_get "$max_time" \ -H "Authorization: token ${GITEE_TOKEN}" \ - "${base}/releases/${release_id}/attach_files" \ - | python3 -c 'import json,sys + "${base}/releases/${release_id}/attach_files")" || return 1 + printf '%s\n' "$payload" | python3 -c 'import json,sys data=json.load(sys.stdin) -rows=data if isinstance(data,list) else data.get("attach_files",[]) +if isinstance(data,list): + rows=data +elif isinstance(data,dict) and "attach_files" in data: + rows=data["attach_files"] +else: + raise ValueError("attachment list response must be a list or contain attach_files") if not isinstance(rows,list): raise ValueError("attach_files must be a list") for asset in rows: + if not isinstance(asset,dict): + raise ValueError("each attachment must be an object") name=asset.get("name","") asset_id=asset.get("id","") url=asset.get("browser_download_url","") - if name and asset_id != "": - print("%s\t%s\t%s" % (name, asset_id, url))' + valid_id=(isinstance(asset_id,int) and not isinstance(asset_id,bool) and asset_id > 0) or (isinstance(asset_id,str) and asset_id.isdigit() and int(asset_id) > 0) + if not isinstance(name,str) or not name or not valid_id or not isinstance(url,str) or not url: + raise ValueError("attachment identity and download URL are required") + print("%s\t%s\t%s" % (name, asset_id, url))' +} + +list_assets() { + local attempt=1 candidate now retry_deadline remaining max_time delay + now="$(now_seconds)" + retry_deadline=$(( now + GITEE_LIST_RETRY_WINDOW_SECONDS )) + if [ "$retry_deadline" -gt "$active_deadline" ]; then + retry_deadline="$active_deadline" + fi + while [ "$attempt" -le "$GITEE_LIST_RETRIES" ]; do + now="$(now_seconds)" + remaining=$(( retry_deadline - now )) + [ "$remaining" -gt 0 ] || break + max_time="$GITEE_LIST_MAX_TIME" + if [ "$max_time" -gt "$remaining" ]; then + max_time="$remaining" + fi + # Publish one complete parse atomically. A malformed response can make the + # parser emit valid leading rows before it fails; leaking those rows into a + # later successful retry would manufacture duplicates and could delete a + # correct attachment. + if candidate="$(list_assets_once "$max_time")"; then + now="$(now_seconds)" + if [ "$now" -lt "$retry_deadline" ]; then + printf '%s\n' "$candidate" + return 0 + fi + break + fi + if [ "$attempt" -ge "$GITEE_LIST_RETRIES" ]; then + break + fi + attempt=$((attempt + 1)) + now="$(now_seconds)" + remaining=$(( retry_deadline - now )) + [ "$remaining" -gt 0 ] || break + delay="$GITEE_LIST_RETRY_DELAY" + if [ "$delay" -gt 0 ]; then + # Do not turn a configured backoff into a burst of zero-delay requests + # during the final wall-clock second. Explicit delay=0 remains available + # to fast unit tests and tightly controlled callers. + [ "$remaining" -gt 1 ] || break + if [ "$delay" -ge "$remaining" ]; then + delay=$(( remaining - 1 )) + fi + fi + echo " ⚠ Gitee attachment list attempt $((attempt - 1))/${GITEE_LIST_RETRIES} failed; retrying in ${delay}s" >&2 + sleep_within_deadline "$delay" || return 1 + done + return 1 } sha256_of() { @@ -234,20 +313,42 @@ delete_named_assets() { } gitee_attach() { - local file="$1" name attempt response status max_time + local file="$1" name attempt response status max_time transfer_log metrics http_code size_upload safe_to_retry name="$(basename "$file")" attempt=1 while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do status=0 max_time="$(bounded_max_time "$GITEE_UPLOAD_MAX_TIME")" || return 1 - if response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \ + transfer_log="$(mktemp)" || return 1 + if metrics="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \ --max-time "$max_time" \ -X POST "${base}/releases/${release_id}/attach_files" \ - -H "Authorization: token ${GITEE_TOKEN}" -F "file=@${file}" 2>&1)"; then + -H "Authorization: token ${GITEE_TOKEN}" \ + -H "Expect:" \ + -F "file=@${file}" \ + -o /dev/null \ + -w '%{http_code}\t%{size_upload}' \ + 2>"$transfer_log")"; then status=0 else status=$? fi + response="$(<"$transfer_log")" + rm -f "$transfer_log" + http_code="${metrics%%$'\t'*}" + if [ "$metrics" = "$http_code" ]; then + size_upload="" + else + size_upload="${metrics#*$'\t'}" + fi + safe_to_retry=0 + if [ "$status" -ne 0 ] && [ "$http_code" = "000" ] && \ + awk -v value="$size_upload" 'BEGIN { exit !((value + 0) == 0 && value ~ /^[0-9]+([.][0-9]+)?$/) }'; then + # A request that never reached HTTP and uploaded zero bytes cannot have + # committed an attachment. TLS/DNS/connect failures are therefore the + # only transport failures safe to replay automatically. + safe_to_retry=1 + fi # Gitee can commit an upload but let the HTTP response time out. Probe the # release before retrying so a lost response does not create duplicates. @@ -259,6 +360,10 @@ gitee_attach() { fi echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${name}: $(printf '%s' "$response" | head -c 240)" >&2 + if [ "$safe_to_retry" -ne 1 ]; then + echo " ⚠ ${name} upload outcome is ambiguous (HTTP ${http_code:-unknown}, uploaded ${size_upload:-unknown} bytes); refusing to replay POST" >&2 + return 1 + fi attempt=$((attempt + 1)) if [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; then # Remove any partial, stale, or duplicate attachment before the one diff --git a/scripts/release/recover-release.sh b/scripts/release/recover-release.sh index 0e6c4a8a..5e4df00e 100755 --- a/scripts/release/recover-release.sh +++ b/scripts/release/recover-release.sh @@ -120,12 +120,41 @@ git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref" } tag_object="$(git rev-parse "$recovery_ref")" commit="$(git rev-parse "$recovery_ref^{commit}")" +tag_message="$(git for-each-ref "$recovery_ref" --format='%(contents)')" +cloud_run_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run" { print $2 }')" +cloud_run_attempt="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run-Attempt" { print $2 }')" +cloud_actor="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By" { print $2 }')" +cloud_actor_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By-ID" { print $2 }')" +cloud_sealed_commit="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Sealed-Commit" { print $2 }')" +cloud_marker_count=0 +for cloud_value in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor" "$cloud_actor_id" "$cloud_sealed_commit"; do + [ -z "$cloud_value" ] || cloud_marker_count=$((cloud_marker_count + 1)) +done +if [ "$cloud_marker_count" -ne 0 ] && [ "$cloud_marker_count" -ne 5 ]; then + printf '%s contains incomplete cloud release metadata\n' "$VERSION" >&2 + exit 1 +fi +if [ "$cloud_marker_count" -eq 5 ]; then + for cloud_number in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor_id"; do + printf '%s\n' "$cloud_number" | grep -Eq '^[1-9][0-9]*$' || { + printf '%s contains invalid cloud release identity\n' "$VERSION" >&2 + exit 1 + } + done + [ "$cloud_sealed_commit" = "$commit" ] || { + printf '%s cloud release metadata is not bound to %s\n' "$VERSION" "$commit" >&2 + exit 1 + } +fi git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || { printf '%s commit %s is not contained in %s/main\n' "$VERSION" "$commit" "$REMOTE" >&2 exit 1 } -if [ -z "$FAILED_RUN_ID" ]; then +if [ -z "$FAILED_RUN_ID" ] && [ "$cloud_marker_count" -eq 5 ]; then + FAILED_RUN_ID="$cloud_run_id" + FAILED_RUN_ATTEMPT="$cloud_run_attempt" +elif [ -z "$FAILED_RUN_ID" ]; then candidate_runs="$( gh api \ -H 'Accept: application/vnd.github+json' \ @@ -160,7 +189,7 @@ attempt_record="$( gh api \ -H 'Accept: application/vnd.github+json' \ "repos/$EXPECTED_REPOSITORY/actions/runs/$FAILED_RUN_ID/attempts/$FAILED_RUN_ATTEMPT" \ - --jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv' + --jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha, .actor.login, .actor.id] | @tsv' )" || { printf 'could not query Release run %s attempt %s\n' "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" >&2 exit 1 @@ -174,6 +203,7 @@ attempt_status="$(printf '%s\n' "$attempt_record" | cut -f6)" attempt_conclusion="$(printf '%s\n' "$attempt_record" | cut -f7)" attempt_branch="$(printf '%s\n' "$attempt_record" | cut -f8)" attempt_commit="$(printf '%s\n' "$attempt_record" | cut -f9)" +attempt_actor_id="$(printf '%s\n' "$attempt_record" | cut -f11)" case "$attempt_conclusion" in failure|cancelled|timed_out|startup_failure|stale) ;; *) @@ -182,18 +212,33 @@ case "$attempt_conclusion" in exit 1 ;; esac +if [ "$cloud_marker_count" -eq 5 ]; then + expected_attempt_event="workflow_dispatch" + expected_attempt_branch="main" +else + expected_attempt_event="push" + expected_attempt_branch="$VERSION" +fi if [ "$attempt_id" != "$FAILED_RUN_ID" ] || [ "$attempt_number" != "$FAILED_RUN_ATTEMPT" ] || [ "$attempt_repository" != "$EXPECTED_REPOSITORY" ] || [ "$attempt_path" != ".github/workflows/release.yml" ] || - [ "$attempt_event" != "push" ] || + [ "$attempt_event" != "$expected_attempt_event" ] || [ "$attempt_status" != "completed" ] || - [ "$attempt_branch" != "$VERSION" ] || + [ "$attempt_branch" != "$expected_attempt_branch" ] || [ "$attempt_commit" != "$commit" ]; then printf 'Release run %s attempt %s does not match %s at %s\n' \ "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" "$commit" >&2 exit 1 fi +if [ "$cloud_marker_count" -eq 5 ] && + { [ "$cloud_run_id" != "$FAILED_RUN_ID" ] || + [ "$cloud_run_attempt" != "$FAILED_RUN_ATTEMPT" ] || + [ "$cloud_actor_id" != "$attempt_actor_id" ]; }; then + printf 'Release run %s attempt %s is not bound by the cloud seal for %s\n' \ + "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" >&2 + exit 1 +fi printf 'Recovery target:\n' printf ' version: %s\n' "$VERSION" diff --git a/scripts/release/release-contract.sh b/scripts/release/release-contract.sh index 71bbb6b1..44c2b89a 100755 --- a/scripts/release/release-contract.sh +++ b/scripts/release/release-contract.sh @@ -71,27 +71,27 @@ git rev-parse --verify --quiet "$remote_main^{commit}" >/dev/null || { printf 'release branch is not available locally: %s/%s\n' "$REMOTE" "$BRANCH" >&2 exit 1 } -remote_main_commit="$(git rev-parse "$remote_main^{commit}")" if [ "$CONTEXT" = "local" ]; then [ -z "$(git status --porcelain --untracked-files=all)" ] || { printf 'release worktree must be clean (staged, unstaged, and untracked files are blocked)\n' >&2 exit 1 } - current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" - [ "$current_branch" = "$BRANCH" ] || { - printf 'local release must run from branch %s (current: %s)\n' "$BRANCH" "${current_branch:-detached HEAD}" >&2 - exit 1 - } - [ "$head_commit" = "$remote_main_commit" ] || { - printf 'HEAD must exactly match %s/%s before release\n' "$REMOTE" "$BRANCH" >&2 + git merge-base --is-ancestor HEAD "$remote_main" || { + printf 'HEAD must be contained in %s/%s history before release\n' "$REMOTE" "$BRANCH" >&2 exit 1 } if git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then printf 'release tag already exists locally: %s\n' "$VERSION" >&2 exit 1 fi - remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || { + if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then + printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2 + exit 1 + fi + remote_tag="$(git ls-remote --tags "$REMOTE" \ + "refs/tags/$VERSION" "refs/tags/$VERSION^{}" \ + "refs/tags/withdrawn/$VERSION" "refs/tags/withdrawn/$VERSION^{}")" || { printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2 exit 1 } @@ -100,6 +100,10 @@ if [ "$CONTEXT" = "local" ]; then exit 1 } else + if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then + printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2 + exit 1 + fi git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || { printf 'CI release tag is not available: %s\n' "$VERSION" >&2 exit 1 @@ -120,32 +124,58 @@ fi previous_stable="" previous_stable_commit="" -for tag in $(git tag --list 'v*' --sort=-version:refname); do +latest_allocated_stable="" +for tag_ref in $(git for-each-ref --format='%(refname)' refs/tags); do + case "$tag_ref" in + refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;; + refs/tags/*) tag="${tag_ref#refs/tags/}" ;; + *) continue ;; + esac [ "$tag" = "$VERSION" ] && continue - if release_is_stable_version "$tag"; then - previous_stable="$tag" - previous_stable_commit="$(git rev-parse "$tag^{commit}")" - break + release_is_stable_version "$tag" || continue + if [ -z "$latest_allocated_stable" ] || + release_core_is_greater "$tag" "$latest_allocated_stable"; then + latest_allocated_stable="$tag" fi done -if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then - printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2 +if [ -n "$latest_allocated_stable" ] && + ! release_core_is_greater "$VERSION" "$latest_allocated_stable"; then + printf 'release version %s must be greater than latest allocated stable %s\n' \ + "$VERSION" "$latest_allocated_stable" >&2 exit 1 fi +for tag in $(git tag --list 'v*' --sort=-version:refname); do + [ "$tag" = "$VERSION" ] && continue + release_is_stable_version "$tag" || continue + if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then + continue + fi + previous_stable="$tag" + previous_stable_commit="$(git rev-parse "$tag^{commit}")" + break +done + core_tag="$(release_core_tag "$VERSION")" if [ "$CHANNEL" = "prerelease" ]; then [ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; } - if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then + if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null || + git rev-parse --verify --quiet "refs/tags/withdrawn/$core_tag" >/dev/null; then printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2 exit 1 fi previous_beta="" - for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do + for tag_ref in $(git for-each-ref --format='%(refname)' \ + "refs/tags/$core_tag-beta.*" "refs/tags/withdrawn/$core_tag-beta.*"); do + case "$tag_ref" in + refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;; + refs/tags/*) tag="${tag_ref#refs/tags/}" ;; + *) continue ;; + esac [ "$tag" = "$VERSION" ] && continue - if release_is_prerelease_version "$tag"; then + release_is_prerelease_version "$tag" || continue + if [ -z "$previous_beta" ] || release_version_is_greater "$tag" "$previous_beta"; then previous_beta="$tag" - break fi done beta_number="$(release_beta_number "$VERSION")" @@ -178,6 +208,10 @@ else printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2 exit 1 } + if git rev-parse --verify --quiet "refs/tags/withdrawn/$FROM_BETA" >/dev/null; then + printf 'stable beta baseline was withdrawn: %s\n' "$FROM_BETA" >&2 + exit 1 + fi git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || { printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2 exit 1 @@ -187,11 +221,6 @@ else printf 'stable beta baseline is not an ancestor of HEAD: %s\n' "$FROM_BETA" >&2 exit 1 } - if ! git diff --quiet "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md'; then - printf 'stable source drifted from %s; only CHANGELOG.md may differ\n' "$FROM_BETA" >&2 - git diff --name-only "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md' >&2 - exit 1 - fi fi semver="$(release_semver "$VERSION")" diff --git a/scripts/release/release-lib.sh b/scripts/release/release-lib.sh index 6738deca..0523a131 100755 --- a/scripts/release/release-lib.sh +++ b/scripts/release/release-lib.sh @@ -130,8 +130,7 @@ release_extract_changelog() { print if ($0 ~ /[^[:space:]]/) meaningful = 1 if ($0 ~ /^- /) bullet = 1 - lowered = tolower($0) - if (lowered ~ /todo|tbd/) placeholder = 1 + if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1 } END { if (found != 1) exit 41 diff --git a/scripts/release/release-tag-oss-mode.sh b/scripts/release/release-tag-oss-mode.sh new file mode 100755 index 00000000..9f486073 --- /dev/null +++ b/scripts/release/release-tag-oss-mode.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +TAG="${1:-}" +[ -n "$TAG" ] || { + printf 'usage: release-tag-oss-mode.sh \n' >&2 + exit 2 +} + +tag_ref="refs/tags/$TAG" +git rev-parse --verify --quiet "$tag_ref" >/dev/null || { + printf 'release tag is not available: %s\n' "$TAG" >&2 + exit 1 +} +tag_object="$(git rev-parse "$tag_ref")" +[ "$(git cat-file -t "$tag_object")" = tag ] || { + printf 'release tag must be annotated: %s\n' "$TAG" >&2 + exit 1 +} +tag_contents="$(git cat-file tag "$tag_object")" || { + printf 'release tag could not be read: %s\n' "$TAG" >&2 + exit 1 +} + +mode="$( + printf '%s\n' "$tag_contents" | + awk ' + { + line = $0 + sub(/\r$/, "", line) + } + line ~ /^OSS-Mirror: / { + count++ + value = substr(line, length("OSS-Mirror: ") + 1) + } + END { + if (count > 1) exit 2 + if (count == 0) print "enabled" + else print value + } + ' +)" || { + printf 'release tag contains duplicate OSS-Mirror metadata: %s\n' "$TAG" >&2 + exit 1 +} +case "$mode" in + enabled|deferred) printf '%s\n' "$mode" ;; + *) + printf 'release tag contains invalid OSS-Mirror metadata: %s (%s)\n' "$TAG" "$mode" >&2 + exit 1 + ;; +esac diff --git a/scripts/release/release.sh b/scripts/release/release.sh index b664f98b..cd98060f 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -134,16 +134,44 @@ require_github_publication_authority() { } sealed_commit="$(git rev-parse HEAD)" - passed_gate="$( + admission_check_runs="$( gh api -H 'Accept: application/vnd.github+json' \ - "repos/$github_repository/commits/$sealed_commit/check-runs?check_name=CI%20Gate&filter=latest&per_page=100" \ - --jq '[.check_runs[] | select(.name == "CI Gate" and .conclusion == "success")] | length' + "repos/$github_repository/commits/$sealed_commit/check-runs?filter=latest&per_page=100" \ + --jq '.check_runs | group_by(.name) | map(max_by(.id)) | .[] | [.name, (.conclusion // .status // "unknown")] | @tsv' )" || { - printf 'could not query CI Gate for %s\n' "$sealed_commit" >&2 + printf 'could not query Code Admission contexts for %s\n' "$sealed_commit" >&2 return 1 } - [ "$passed_gate" -gt 0 ] || { - printf 'CI Gate has not succeeded for sealed commit %s in %s\n' "$sealed_commit" "$github_repository" >&2 + + missing_contexts="" + non_success_contexts="" + while IFS= read -r required_context; do + context_state="$( + printf '%s\n' "$admission_check_runs" | + awk -F '\t' -v required="$required_context" '$1 == required { state = $2 } END { print state }' + )" + if [ -z "$context_state" ]; then + missing_contexts="${missing_contexts}${missing_contexts:+, }$required_context" + elif [ "$context_state" != "success" ]; then + non_success_contexts="${non_success_contexts}${non_success_contexts:+, }$required_context=$context_state" + fi + done <<'ADMISSION_CONTEXTS' +Lint +Test +Coverage +Policy +Edition +Interface Integrity +AI Behavior +CLI Smoke +Mock MCP +ADMISSION_CONTEXTS + [ -z "$missing_contexts" ] && [ -z "$non_success_contexts" ] || { + printf 'Code Admission contexts are not all successful for sealed commit %s in %s; missing: %s; non-success: %s\n' \ + "$sealed_commit" \ + "$github_repository" \ + "${missing_contexts:-none}" \ + "${non_success_contexts:-none}" >&2 return 1 } @@ -260,8 +288,12 @@ build_policy_binary() { } fetch_release_tags() { - git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*' - git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*' + git fetch --force "$REMOTE" \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' + git fetch --force --no-tags "$OFFICIAL_TAGS_URL" \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' } printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH" @@ -319,7 +351,7 @@ else if [ -n "$previous_stable" ]; then printf '==> Comparing command tree with %s\n' "$previous_stable" "$ROOT/scripts/policy/check-command-compatibility.sh" \ - --base-ref "$REMOTE/$BRANCH" \ + --base-ref HEAD \ --stable-ref "$previous_stable" fi @@ -373,7 +405,7 @@ if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \ "${previous_stable_before_refresh:-none}" "$previous_stable" "$ROOT/scripts/policy/check-command-compatibility.sh" \ - --base-ref "$REMOTE/$BRANCH" \ + --base-ref HEAD \ --stable-ref "$previous_stable" fi @@ -384,9 +416,9 @@ fi # Delivery, compatibility, and publication checks above may take long enough # for main or stable authority to move. This last refresh must be followed only -# by local proof/tag creation. The atomic push advertises main with the tag, so -# an already-advanced remote main rejects the whole transaction; a later main -# advance is safe because the sealed commit remains in protected main history. +# by local proof/tag creation. Only the tag is pushed: the sealed commit is +# already contained in protected main history, so a later main advance never +# invalidates the release. printf '==> Settling final %s/%s and stable authority\n' "$REMOTE" "$BRANCH" git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH" fetch_release_tags @@ -415,8 +447,7 @@ else git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease' fi -if ! git push --atomic "$push_url" \ - "HEAD:refs/heads/$BRANCH" "refs/tags/$VERSION"; then +if ! git push "$push_url" "refs/tags/$VERSION"; then set +e remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" query_status=$? diff --git a/scripts/release/sync-to-gitee.sh b/scripts/release/sync-to-gitee.sh index 87c2965d..ad903589 100755 --- a/scripts/release/sync-to-gitee.sh +++ b/scripts/release/sync-to-gitee.sh @@ -31,11 +31,13 @@ DIST_DIR="${DIST_DIR:-dist}" GITEE_API="${GITEE_API:-https://gitee.com/api/v5}" GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}" GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}" -GITEE_SYNC_TIMEOUT_SECONDS="${GITEE_SYNC_TIMEOUT_SECONDS:-5700}" +GITEE_SYNC_TIMEOUT_SECONDS="${GITEE_SYNC_TIMEOUT_SECONDS:-18840}" GITEE_TAG_TIMEOUT_SECONDS="${GITEE_TAG_TIMEOUT_SECONDS:-300}" GITEE_RELEASE_LOOKUP_MAX_TIME="${GITEE_RELEASE_LOOKUP_MAX_TIME:-60}" +GITEE_RELEASE_LOOKUP_RETRIES="${GITEE_RELEASE_LOOKUP_RETRIES:-2}" +GITEE_RELEASE_LOOKUP_RETRY_DELAY="${GITEE_RELEASE_LOOKUP_RETRY_DELAY:-2}" GITEE_RELEASE_CREATE_MAX_TIME="${GITEE_RELEASE_CREATE_MAX_TIME:-60}" -GITEE_RECONCILE_TIMEOUT_SECONDS="${GITEE_RECONCILE_TIMEOUT_SECONDS:-4920}" +GITEE_RECONCILE_TIMEOUT_SECONDS="${GITEE_RECONCILE_TIMEOUT_SECONDS:-18300}" GITEE_CHILD_DEADLINE_RESERVE_SECONDS="${GITEE_CHILD_DEADLINE_RESERVE_SECONDS:-5}" err() { @@ -51,17 +53,28 @@ require_positive_integer() { [ "$value" -gt 0 ] || err "${name} must be greater than zero" } +require_nonnegative_integer() { + local name="$1" value="$2" + case "$value" in + ''|*[!0-9]*) err "${name} must be a non-negative integer: ${value}" ;; + esac +} + for setting in \ GITEE_CURL_CONNECT_TIMEOUT \ GITEE_CURL_MAX_TIME \ GITEE_SYNC_TIMEOUT_SECONDS \ GITEE_TAG_TIMEOUT_SECONDS \ GITEE_RELEASE_LOOKUP_MAX_TIME \ + GITEE_RELEASE_LOOKUP_RETRIES \ GITEE_RELEASE_CREATE_MAX_TIME \ GITEE_RECONCILE_TIMEOUT_SECONDS \ GITEE_CHILD_DEADLINE_RESERVE_SECONDS; do require_positive_integer "$setting" "${!setting}" done +require_nonnegative_integer \ + GITEE_RELEASE_LOOKUP_RETRY_DELAY \ + "$GITEE_RELEASE_LOOKUP_RETRY_DELAY" missing="" [ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN" @@ -117,14 +130,6 @@ GITEE_TAG_TIMEOUT_SECONDS="$GITEE_TAG_TIMEOUT_SECONDS" \ deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during tag synchronization" target_commit="$(git rev-parse --verify "${VERSION}^{commit}")" -api_get() { - local configured_max_time="$1" max_time - shift - max_time="$(bounded_max_time "$configured_max_time")" || return 1 - curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \ - --max-time "$max_time" "$@" -} - release_id_from_json() { python3 -c 'import json, sys data = json.load(sys.stdin) @@ -136,12 +141,41 @@ elif isinstance(value, str) and value.isdigit() and int(value) > 0: } # ── Resolve or create the Gitee release for this tag ────────────────────────── -rel_json="$(api_get "$GITEE_RELEASE_LOOKUP_MAX_TIME" \ +# A transient lookup failure must not be mistaken for a missing release: doing +# so can race a duplicate create and hides the actual Gitee availability issue. +release_lookup_body="$(mktemp)" +trap 'rm -f "$release_lookup_body"' EXIT HUP INT TERM +lookup_max_time="$(bounded_max_time "$GITEE_RELEASE_LOOKUP_MAX_TIME")" \ + || err "overall Gitee sync deadline exhausted before release lookup" +if ! release_status="$(curl -sS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \ + --max-time "$lookup_max_time" \ + --retry "$GITEE_RELEASE_LOOKUP_RETRIES" \ + --retry-all-errors \ + --retry-delay "$GITEE_RELEASE_LOOKUP_RETRY_DELAY" \ + --retry-max-time "$lookup_max_time" \ + -o "$release_lookup_body" \ + -w '%{http_code}' \ -H "Authorization: token ${GITEE_TOKEN}" \ - "${base}/releases/tags/${VERSION}" 2>/dev/null || true)" -release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)" + "${base}/releases/tags/${VERSION}")"; then + deadline_remaining >/dev/null \ + || err "overall Gitee sync deadline exhausted during release lookup" + err "could not query Gitee release ${VERSION} after bounded retries" +fi +rel_json="$(<"$release_lookup_body")" +case "$release_status" in + 200) + release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)" + [ -n "$release_id" ] || err "Gitee release lookup returned HTTP 200 without a valid release id" + ;; + 404) + release_id="" + ;; + *) + err "Gitee release lookup returned HTTP ${release_status} for ${VERSION}" + ;; +esac -if [ -z "$release_id" ]; then +if [ "$release_status" = "404" ]; then deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during release lookup" echo " No Gitee release for ${VERSION} yet — creating it." create_max_time="$(bounded_max_time "$GITEE_RELEASE_CREATE_MAX_TIME")" \ diff --git a/scripts/release/sync-to-oss.sh b/scripts/release/sync-to-oss.sh index ce12085c..e0462ba4 100755 --- a/scripts/release/sync-to-oss.sh +++ b/scripts/release/sync-to-oss.sh @@ -47,6 +47,19 @@ if [ -n "$missing" ]; then exit 0 fi +# ossutil v2 signs with V4 and requires an explicit region. Derive it from the +# endpoint host (oss-[-internal].aliyuncs.com) unless OSS_REGION is set. +if [ -z "${OSS_REGION:-}" ]; then + OSS_REGION="$(printf '%s' "$OSS_ENDPOINT" \ + | sed -n 's#^\(https\{0,1\}://\)\{0,1\}oss-\([a-z0-9-]*[a-z0-9]\)\.aliyuncs\.com.*#\2#p' \ + | sed 's#-internal$##')" +fi +if [ -z "$OSS_REGION" ]; then + echo "❌ Could not derive OSS_REGION from OSS_ENDPOINT=${OSS_ENDPOINT}; set OSS_REGION explicitly." >&2 + exit 1 +fi +export OSS_REGION + # ── Resolve version ────────────────────────────────────────────────────────── VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}" CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}" diff --git a/scripts/release/verify-delivered-stable.sh b/scripts/release/verify-delivered-stable.sh index 2e83fa2b..28b2dd69 100755 --- a/scripts/release/verify-delivered-stable.sh +++ b/scripts/release/verify-delivered-stable.sh @@ -82,7 +82,7 @@ if [ "$delivered_by_push" -ne 1 ]; then if DWS_RELEASE_OFFICIAL_REPOSITORY="$REPOSITORY" \ DWS_RELEASE_GITHUB_TOKEN="$API_TOKEN" \ "$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$TAG" "$EXPECTED_COMMIT" 2>/dev/null; then - printf 'Delivered stable baseline verified through protected default-branch recovery: %s -> %s\n' \ + printf 'Delivered stable baseline verified through trusted default-branch delivery: %s -> %s\n' \ "$TAG" "$EXPECTED_COMMIT" exit 0 fi diff --git a/scripts/release/verify-github-release-assets.sh b/scripts/release/verify-github-release-assets.sh index 3d80771d..ffa347a4 100755 --- a/scripts/release/verify-github-release-assets.sh +++ b/scripts/release/verify-github-release-assets.sh @@ -3,13 +3,32 @@ set -eu TAG="${1:-}" REPOSITORY="${GITHUB_REPOSITORY:-}" +RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}" [ -n "$TAG" ] && [ -n "$REPOSITORY" ] || { - printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh \n' >&2 + printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] verify-github-release-assets.sh \n' >&2 exit 2 } command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; } +if [ -z "$RELEASE_ID" ]; then + RELEASE_ID="$( + gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json databaseId,isDraft \ + --jq 'select(.isDraft == true) | .databaseId' 2>/dev/null || true + )" +fi +if [ -n "$RELEASE_ID" ]; then + printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2 + exit 2 + } + release_endpoint="repos/$REPOSITORY/releases/$RELEASE_ID" +else + release_endpoint="repos/$REPOSITORY/releases/tags/$TAG" +fi + tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")" trap 'rm -rf "$tmp"' EXIT HUP INT TERM cat > "$tmp/expected" <<'EOF' @@ -24,8 +43,17 @@ dws-windows-arm64.zip EOF LC_ALL=C sort "$tmp/expected" -o "$tmp/expected" +resolved_tag="$( + gh api -H 'Accept: application/vnd.github+json' \ + "$release_endpoint" \ + --jq '.tag_name' +)" +[ "$resolved_tag" = "$TAG" ] || { + printf 'GitHub Release ID/tag mismatch: expected %s, got %s\n' "$TAG" "$resolved_tag" >&2 + exit 1 +} gh api -H 'Accept: application/vnd.github+json' \ - "repos/$REPOSITORY/releases/tags/$TAG" \ + "$release_endpoint" \ --jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual" if ! diff -u "$tmp/expected" "$tmp/actual"; then printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2 diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 886afbbc..2d517273 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -53,8 +53,6 @@ while [ "$#" -gt 0 ]; do esac done -[ -z "$EXPECTED_VERSION" ] || need_cmd strings - TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")" HOME_AGENT_PARENTS=" .claude @@ -145,8 +143,8 @@ verify_npm() { if [ -n "$EXPECTED_VERSION" ]; then vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" need_file "$vendor_bin" - strings "$vendor_bin" | grep -Fqx "v$EXPECTED_VERSION" || \ - err "npm-installed binary does not embed expected version v$EXPECTED_VERSION" + LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$vendor_bin" || \ + err "npm-installed binary does not contain expected version marker v$EXPECTED_VERSION" EXPECTED_VERSION="$EXPECTED_VERSION" node -e ' const pkg = require(process.argv[1]); if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); @@ -193,8 +191,8 @@ verify_brew() { [ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws" "$prefix/bin/dws" --help >/dev/null if [ -n "$EXPECTED_VERSION" ]; then - strings "$prefix/bin/dws" | grep -Fqx "v$EXPECTED_VERSION" || \ - err "Homebrew-installed binary does not embed expected version v$EXPECTED_VERSION" + LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$prefix/bin/dws" || \ + err "Homebrew-installed binary does not contain expected version marker v$EXPECTED_VERSION" fi need_file "$prefix/share/dingtalk-workspace-cli-local/skills/dws/SKILL.md" } diff --git a/scripts/release/verify-release-artifacts.sh b/scripts/release/verify-release-artifacts.sh index 0ddc65df..f7602cc3 100755 --- a/scripts/release/verify-release-artifacts.sh +++ b/scripts/release/verify-release-artifacts.sh @@ -94,7 +94,7 @@ verify_binary_version() { printf '%s does not contain the expected dws binary\n' "$asset" >&2 return 1 } - strings "$binary" | grep -Fqx "v$SEMVER" || { + LC_ALL=C grep -aFq "v$SEMVER" "$binary" || { printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2 return 1 } diff --git a/scripts/release/verify-release-workflow-delivery.sh b/scripts/release/verify-release-workflow-delivery.sh index f06397ee..0a264000 100755 --- a/scripts/release/verify-release-workflow-delivery.sh +++ b/scripts/release/verify-release-workflow-delivery.sh @@ -4,12 +4,31 @@ set -eu SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" . "$SCRIPT_DIR/release-lib.sh" +MODE="strict" +CHANNEL_REPAIR_TARGET="" +if [ "${1:-}" = "--channel-repair" ]; then + MODE="channel-repair" + shift + CHANNEL_REPAIR_TARGET="${1:-}" + shift + case "$CHANNEL_REPAIR_TARGET" in + oss|gitee) ;; + *) + printf 'channel repair target must be oss or gitee\n' >&2 + exit 2 + ;; + esac +elif [ "${1:-}" = "--npm-repair" ]; then + MODE="npm-repair" + shift +fi + TAG="${1:-}" EXPECTED_COMMIT="${2:-}" REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" [ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || { - printf 'usage: verify-release-workflow-delivery.sh \n' >&2 + printf 'usage: verify-release-workflow-delivery.sh [--channel-repair | --npm-repair] \n' >&2 exit 2 } if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then @@ -65,6 +84,509 @@ for run in runs: done } +find_cloud_delivery_identity() { + tag_ref="$( + github_get "repos/$REPOSITORY/git/ref/tags/$TAG" \ + | python3 -c 'import json,sys +ref=json.load(sys.stdin) +obj=ref.get("object", {}) +if obj.get("type") == "tag" and obj.get("sha"): + print(obj["sha"])' + )" || return 1 + [ -n "$tag_ref" ] || return 1 + github_get "repos/$REPOSITORY/git/tags/$tag_ref" \ + | python3 -c 'import json,re,sys +tag,commit=sys.argv[1:] +payload=json.load(sys.stdin) +if payload.get("tag") != tag or payload.get("object", {}).get("type") != "commit": + raise SystemExit(1) +if payload.get("object", {}).get("sha") != commit: + raise SystemExit(1) +fields={} +for line in payload.get("message", "").splitlines(): + if ": " not in line: + continue + key,value=line.split(": ", 1) + if key in fields: + raise SystemExit(1) + fields[key]=value +required={ + "Channel", "Release-Run", "Release-Run-Attempt", "Requested-By", + "Requested-By-ID", "Sealed-Commit", "Workflow-Commit", + "Allocation-Fingerprint", +} +if not required.issubset(fields): + raise SystemExit(1) +if fields["Sealed-Commit"] != commit or fields["Workflow-Commit"] != commit: + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run"]): + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run-Attempt"]): + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Requested-By-ID"]): + raise SystemExit(1) +if not fields["Requested-By"] or not re.fullmatch(r"[0-9a-f]{64}", fields["Allocation-Fingerprint"]): + raise SystemExit(1) +is_beta="-beta." in tag +if fields["Channel"] != ("prerelease" if is_beta else "stable"): + raise SystemExit(1) +from_beta=fields.get("From-Beta", "") +if is_beta: + if from_beta: + raise SystemExit(1) +else: + core=re.escape(tag) + if not re.fullmatch(core + r"-beta\.[1-9][0-9]*", from_beta): + raise SystemExit(1) +print("\t".join([ + fields["Release-Run"], + fields["Release-Run-Attempt"], + fields["Requested-By"], + fields["Requested-By-ID"], + fields["Workflow-Commit"], +]))' "$TAG" "$EXPECTED_COMMIT" +} + +verify_cloud_delivery() { + cloud_identity="$1" + cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)" + cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)" + cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)" + cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)" + cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)" + [ -n "$cloud_run_id" ] && [ -n "$cloud_run_attempt" ] && + [ -n "$cloud_actor" ] && [ -n "$cloud_actor_id" ] && + [ -n "$cloud_workflow_sha" ] || return 1 + + cloud_run_state="$( + github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \ + | python3 -c 'import json,sys +r=json.load(sys.stdin) +print("\t".join(str(value) for value in ( + r.get("id", ""), + r.get("run_attempt", ""), + r.get("repository", {}).get("full_name", ""), + r.get("path", ""), + r.get("event", ""), + r.get("status", ""), + r.get("conclusion", ""), + r.get("head_branch", ""), + r.get("head_sha", ""), + r.get("actor", {}).get("login", ""), + r.get("actor", {}).get("id", ""), +)))' + )" || return 1 + expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s' \ + "$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")" + [ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] || + return 1 + [ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] || return 1 + [ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ] || + return 1 + [ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1 + + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-cloud-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + page_count="$( + python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \ + "$jobs_page" + )" || { + rm -rf "$jobs_dir" + return 1 + } + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$cloud_workflow_sha" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +workflow_sha, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +required = ( + "Plan next cloud release", + "Seal cloud release tag", + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", + "Publish immutable GitHub Release", + "Publish npm and mirrors", + "Release delivery gate", +) +for name in required: + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + raise SystemExit(1) + job = matches[0] + if ( + job.get("head_sha") != workflow_sha + or job.get("status") != "completed" + or job.get("conclusion") != "success" + ): + raise SystemExit(1) + +seal = next(job for job in jobs if job.get("name") == "Seal cloud release tag") +steps = [ + step for step in seal.get("steps", []) + if step.get("name") == "Create one immutable annotated release tag" +] +if ( + len(steps) != 1 + or steps[0].get("status") != "completed" + or steps[0].get("conclusion") != "success" +): + raise SystemExit(1) +PY + rm -rf "$jobs_dir" + return "$result" +} + +verify_failed_cloud_delivery_identity() { + cloud_identity="$1" + cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)" + cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)" + cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)" + cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)" + cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)" + [ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1 + cloud_run_state="$( + github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \ + | python3 -c 'import json,sys +r=json.load(sys.stdin) +print("\t".join(str(value) for value in ( + r.get("id", ""), + r.get("run_attempt", ""), + r.get("repository", {}).get("full_name", ""), + r.get("path", ""), + r.get("event", ""), + r.get("status", ""), + r.get("conclusion", ""), + r.get("head_branch", ""), + r.get("head_sha", ""), + r.get("actor", {}).get("login", ""), + r.get("actor", {}).get("id", ""), +)))' + )" || return 1 + expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tfailure\tmain\t%s' \ + "$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")" + [ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] && + [ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] && + [ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ] +} + +find_failed_push_delivery() { + matches="" + page=1 + while :; do + page_result="$( + github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100&page=$page" \ + | python3 -c 'import json,sys +tag,commit,repository=sys.argv[1:] +runs=json.load(sys.stdin).get("workflow_runs", []) +print(len(runs)) +for run in runs: + if (run.get("head_sha") == commit and run.get("head_branch") == tag + and run.get("event") == "push" and run.get("status") == "completed" + and run.get("conclusion") == "failure" + and run.get("path") == ".github/workflows/release.yml" + and run.get("repository", {}).get("full_name") == repository): + run_id=run.get("id", "") + attempt=run.get("run_attempt", "") + if isinstance(run_id, int) and run_id > 0 and isinstance(attempt, int) and attempt > 0: + print(f"{run_id}\t{attempt}")' "$TAG" "$EXPECTED_COMMIT" "$REPOSITORY" + )" || return 1 + page_count="$(printf '%s\n' "$page_result" | sed -n '1p')" + page_matches="$(printf '%s\n' "$page_result" | sed '1d')" + matches="$(printf '%s\n%s\n' "$matches" "$page_matches" | sed '/^$/d')" + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + match_count="$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')" + [ "$match_count" -eq 1 ] || { + printf 'expected exactly one failed exact-tag push run for channel repair, found %s\n' \ + "$match_count" >&2 + return 1 + } + printf '%s\n' "$matches" +} + +verify_channel_repair_delivery() { + run_id="$1" + run_attempt="$2" + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-channel-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$run_id/attempts/$run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + if ! page_count="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' "$jobs_page")"; then + rm -rf "$jobs_dir" + return 1 + fi + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$EXPECTED_COMMIT" "$run_id" "$run_attempt" "$TAG" "$CHANNEL_REPAIR_TARGET" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +commit, run_id, run_attempt, tag, target, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +def fail(message): + print( + f"failed Release run {run_id} attempt {run_attempt} is not safe " + f"channel-repair authority for {tag}: {message}", + file=sys.stderr, + ) + raise SystemExit(1) + +def one_job(name): + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + fail(f"expected exactly one latest-attempt job {name!r}, found {len(matches)}") + job = matches[0] + if job.get("head_sha") != commit: + fail(f"job {name!r} is not bound to {commit}") + if job.get("status") != "completed": + fail(f"job {name!r} is not completed") + return job + +for name in ( + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", + "Publish immutable GitHub Release", +): + if one_job(name).get("conclusion") != "success": + fail(f"required job {name!r} did not succeed") + +publish_release = one_job("Publish immutable GitHub Release") +immutable_steps = [ + step for step in publish_release.get("steps", []) + if step.get("name") == "Require immutable published GitHub Release" +] +if len(immutable_steps) != 1: + fail("expected exactly one immutable GitHub Release verification step") +if ( + immutable_steps[0].get("status") != "completed" + or immutable_steps[0].get("conclusion") != "success" +): + fail("immutable GitHub Release verification did not succeed") + +channels = one_job("Publish npm and mirrors") +if channels.get("conclusion") not in {"success", "failure"}: + fail("channel publication job was not completed with a conclusive result") + +steps = channels.get("steps", []) +for name in ( + "Download and verify immutable GitHub Release", + "Verify immutable npm package without publication credentials", + "Inspect npm channel state", + "Verify npm channel delivery", +): + matches = [step for step in steps if step.get("name") == name] + if len(matches) != 1: + fail(f"expected exactly one channel step {name!r}, found {len(matches)}") + step = matches[0] + if step.get("status") != "completed" or step.get("conclusion") != "success": + fail(f"required channel step {name!r} did not succeed") + +failed_channel_steps = [ + step.get("name", "") + for step in steps + if step.get("conclusion") == "failure" +] +if channels.get("conclusion") == "failure": + if failed_channel_steps != ["Sync release artifacts to China OSS mirror"]: + fail( + "failed channel publication must have exactly one failed OSS mirror step, " + f"got {failed_channel_steps!r}" + ) +elif failed_channel_steps: + fail(f"successful channel publication contains failed steps {failed_channel_steps!r}") + +gitee = one_job("Mirror immutable release to Gitee") +if gitee.get("conclusion") not in {"success", "skipped", "failure"}: + fail("Gitee mirror job did not complete with an allowed channel result") + +delivery_gate = one_job("Release delivery gate") +if delivery_gate.get("conclusion") != "failure": + fail("failed channel-repair run must end in a failed delivery gate") + +allowed_failures = { + "Publish npm and mirrors", + "Mirror immutable release to Gitee", + "Release delivery gate", +} +for job in jobs: + if job.get("status") != "completed": + fail(f"job {job.get('name', '')!r} is not completed") + conclusion = job.get("conclusion") + if conclusion not in {"success", "skipped", "failure"}: + fail(f"job {job.get('name', '')!r} has disallowed conclusion {conclusion!r}") + if conclusion == "failure" and job.get("name") not in allowed_failures: + fail(f"unrelated job {job.get('name', '')!r} failed") + +business_failures = [ + job.get("name") + for job in (channels, gitee) + if job.get("conclusion") == "failure" +] +if len(business_failures) != 1: + fail(f"expected exactly one failed downstream channel job, got {business_failures!r}") +if target == "oss": + if business_failures != ["Publish npm and mirrors"] or gitee.get("conclusion") != "skipped": + fail( + "OSS repair requires the OSS mirror step to be the only failed " + "downstream channel and Gitee to be skipped" + ) +elif target == "gitee": + if gitee.get("conclusion") == "failure": + if business_failures != ["Mirror immutable release to Gitee"]: + fail("Gitee repair evidence contains a different failed downstream channel") + elif gitee.get("conclusion") == "skipped": + if business_failures != ["Publish npm and mirrors"]: + fail( + "skipped Gitee backfill requires the upstream OSS mirror to be " + "the only failed downstream channel" + ) + else: + fail("Gitee repair requires its mirror job to be failed or skipped") +else: + fail(f"unsupported channel repair target {target!r}") +PY + rm -rf "$jobs_dir" + return "$result" +} + +verify_npm_repair_delivery() { + run_id="$1" + run_attempt="$2" + require_cloud_seal="$3" + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-npm-repair-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$run_id/attempts/$run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + if ! page_count="$( + python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \ + "$jobs_page" + )"; then + rm -rf "$jobs_dir" + return 1 + fi + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$EXPECTED_COMMIT" "$run_id" "$run_attempt" "$TAG" "$require_cloud_seal" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +commit, run_id, run_attempt, tag, require_cloud_seal, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +def fail(message): + print( + f"Release run {run_id} attempt {run_attempt} is not safe npm-repair " + f"authority for {tag}: {message}", + file=sys.stderr, + ) + raise SystemExit(1) + +def one_job(name): + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + fail(f"expected exactly one job {name!r}, found {len(matches)}") + job = matches[0] + if ( + job.get("head_sha") != commit + or job.get("status") != "completed" + or job.get("conclusion") != "success" + ): + fail(f"required job {name!r} did not succeed at {commit}") + return job + +for name in ( + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", +): + one_job(name) + +if require_cloud_seal == "true": + one_job("Plan next cloud release") + seal = one_job("Seal cloud release tag") + seal_steps = [ + step for step in seal.get("steps", []) + if step.get("name") == "Create one immutable annotated release tag" + ] + if ( + len(seal_steps) != 1 + or seal_steps[0].get("status") != "completed" + or seal_steps[0].get("conclusion") != "success" + ): + fail("cloud release seal step did not succeed") +elif require_cloud_seal != "false": + fail(f"invalid cloud seal requirement {require_cloud_seal!r}") + +published = one_job("Publish immutable GitHub Release") +steps = [ + step for step in published.get("steps", []) + if step.get("name") == "Require immutable published GitHub Release" +] +if ( + len(steps) != 1 + or steps[0].get("status") != "completed" + or steps[0].get("conclusion") != "success" +): + fail("immutable GitHub Release verification step did not succeed") + +channels = [job for job in jobs if job.get("name") == "Publish npm and mirrors"] +if len(channels) != 1: + fail(f"expected exactly one npm publication job, found {len(channels)}") +channel = channels[0] +if ( + channel.get("head_sha") != commit + or channel.get("status") != "completed" + or channel.get("conclusion") not in {"success", "failure"} +): + fail("npm publication job is not a completed success/failure at the release commit") +PY + rm -rf "$jobs_dir" + return "$result" +} + push_delivery="$(find_push_delivery || true)" if [ -n "$push_delivery" ]; then printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \ @@ -72,6 +594,59 @@ if [ -n "$push_delivery" ]; then exit 0 fi +cloud_delivery_identity="$(find_cloud_delivery_identity || true)" +if [ -n "$cloud_delivery_identity" ] && + verify_cloud_delivery "$cloud_delivery_identity"; then + cloud_delivery_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + printf 'Release workflow delivery verified through cloud release run %s: %s -> %s\n' \ + "$cloud_delivery_run" "$TAG" "$EXPECTED_COMMIT" + exit 0 +fi + +if [ "$MODE" = "channel-repair" ]; then + failed_push_identity="$(find_failed_push_delivery || true)" + failed_push_delivery="$(printf '%s\n' "$failed_push_identity" | cut -f1)" + failed_push_attempt="$(printf '%s\n' "$failed_push_identity" | cut -f2)" + if [ -n "$failed_push_delivery" ] && + verify_channel_repair_delivery "$failed_push_delivery" "$failed_push_attempt"; then + printf 'Release %s channel-repair authority verified through failed exact-tag push run %s attempt %s: %s -> %s\n' \ + "$CHANNEL_REPAIR_TARGET" "$failed_push_delivery" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + if [ -n "$cloud_delivery_identity" ] && + verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then + failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)" + if verify_channel_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt"; then + printf 'Release %s channel-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \ + "$CHANNEL_REPAIR_TARGET" "$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + fi +fi + +if [ "$MODE" = "npm-repair" ]; then + failed_push_identity="$(find_failed_push_delivery || true)" + failed_push_run="$(printf '%s\n' "$failed_push_identity" | cut -f1)" + failed_push_attempt="$(printf '%s\n' "$failed_push_identity" | cut -f2)" + if [ -n "$failed_push_run" ] && + verify_npm_repair_delivery "$failed_push_run" "$failed_push_attempt" false; then + printf 'Release npm-repair authority verified through failed exact-tag push run %s attempt %s: %s -> %s\n' \ + "$failed_push_run" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + if [ -n "$cloud_delivery_identity" ] && + verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then + failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)" + if verify_npm_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt" true; then + printf 'Release npm-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \ + "$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + fi +fi + find_recovery_identity() { page=1 while :; do @@ -103,7 +678,7 @@ for run in runs: recovery_identity="$(find_recovery_identity || true)" [ -n "$recovery_identity" ] || { - printf 'Release workflow did not deliver %s at %s through a tag push or protected recovery\n' \ + printf 'Release workflow did not deliver %s at %s through a tag push, cloud release, or protected recovery\n' \ "$TAG" "$EXPECTED_COMMIT" >&2 exit 1 } diff --git a/scripts/release/withdraw-release.sh b/scripts/release/withdraw-release.sh new file mode 100755 index 00000000..7550b111 --- /dev/null +++ b/scripts/release/withdraw-release.sh @@ -0,0 +1,1215 @@ +#!/usr/bin/env bash +# Withdraw one already-published DWS release from every distribution channel +# without pretending that installed clients can be downgraded. +# +# The withdrawal is deliberately resumable. A permanent annotated tombstone is +# created before any channel mutation. Reruns accept only that exact tombstone; +# the ref is never updated or deleted. + +set -euo pipefail + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +ROOT="$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/release/release-lib.sh +. "$SCRIPT_DIR/release-lib.sh" + +VERSION="${1:-}" +REASON="${2:-}" +CONFIRMATION="${3:-}" + +OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" +PACKAGE_NAME="${DWS_NPM_PACKAGE:-dingtalk-workspace-cli}" +DEFAULT_BRANCH="${DWS_DEFAULT_BRANCH:-${GITHUB_EVENT_DEFAULT_BRANCH:-}}" +TOMBSTONE="withdrawn/${VERSION}" +GITEE_API="${GITEE_API:-https://gitee.com/api/v5}" +GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" +OSS_PREFIX="${OSS_PREFIX:-dws}" +OSS_ENABLED="" +DELIVERY_VERIFIER="${DWS_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-release-workflow-delivery.sh}" +STABLE_DELIVERY_VERIFIER="${DWS_STABLE_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-delivered-stable.sh}" +GITHUB_DOWNLOAD_HELPER="${DWS_GITHUB_DOWNLOAD_HELPER:-$SCRIPT_DIR/download-github-release-assets.sh}" +ARTIFACT_VERIFY_HELPER="${DWS_ARTIFACT_VERIFY_HELPER:-$SCRIPT_DIR/verify-release-artifacts.sh}" +GITEE_SYNC_HELPER="${DWS_GITEE_SYNC_HELPER:-$SCRIPT_DIR/sync-to-gitee.sh}" + +err() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +say() { + printf '%s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || err "missing required command: $1" +} + +need_env() { + local name="$1" value="$2" + [ -n "$value" ] || err "missing required environment variable: $name" +} + +sha256_text() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} + +github_api() { + gh api -H 'X-GitHub-Api-Version: 2026-03-10' "$@" +} + +github_optional() { + local error_file status + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-optional.XXXXXX")" + set +e + github_api "$@" 2>"$error_file" + status=$? + set -e + if [ "$status" -eq 0 ]; then + rm -f "$error_file" + return 0 + fi + if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + rm -f "$error_file" + return 4 + fi + cat "$error_file" >&2 + rm -f "$error_file" + return "$status" +} + +github_expect_404() { + local endpoint="$1" error_file + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-404.XXXXXX")" + if github_api "$endpoint" >/dev/null 2>"$error_file"; then + rm -f "$error_file" + err "GitHub resource still exists after deletion: $endpoint" + fi + if ! grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + cat "$error_file" >&2 + rm -f "$error_file" + err "could not prove GitHub resource deletion: $endpoint" + fi + rm -f "$error_file" +} + +github_delete_if_present() { + local endpoint="$1" description="$2" error_file status + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-delete.XXXXXX")" + set +e + github_api --method DELETE "$endpoint" >/dev/null 2>"$error_file" + status=$? + set -e + if [ "$status" -eq 0 ]; then + rm -f "$error_file" + return 0 + fi + if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + rm -f "$error_file" + say "$description was already absent." + return 0 + fi + cat "$error_file" >&2 + rm -f "$error_file" + err "could not delete $description" +} + +release_json() { + github_api "repos/${OFFICIAL_REPOSITORY}/releases/tags/$1" +} + +release_field() { + local version="$1" expression="$2" + release_json "$version" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +path = sys.argv[1].split(".") +value = data +for item in path: + if item: + value = value.get(item) if isinstance(value, dict) else None +if value is True: + print("true") +elif value is False: + print("false") +elif value is not None: + print(value) +' "$expression" +} + +tag_commit() { + git rev-parse --verify "$1^{commit}" 2>/dev/null +} + +tag_object() { + git rev-parse --verify "refs/tags/$1" 2>/dev/null +} + +has_tombstone() { + git show-ref --verify --quiet "refs/tags/withdrawn/$1" +} + +expected_prerelease() { + if [ "$1" = prerelease ]; then + printf '%s\n' true + else + printf '%s\n' false + fi +} + +npm_exact_version() { + npm view "${PACKAGE_NAME}@${1#v}" version --registry=https://registry.npmjs.org 2>/dev/null +} + +npm_deprecation() { + npm view "${PACKAGE_NAME}@${1#v}" deprecated --registry=https://registry.npmjs.org 2>/dev/null +} + +release_is_eligible_rollback() { + local candidate="$1" channel="$2" state commit deprecation immutable + + release_validate_version_channel "$channel" "$candidate" >/dev/null 2>&1 || return 1 + has_tombstone "$candidate" && return 1 + [ "$(git cat-file -t "$(tag_object "$candidate")" 2>/dev/null || true)" = tag ] || return 1 + commit="$(tag_commit "$candidate" 2>/dev/null || true)" + printf '%s\n' "$commit" | grep -Eq '^[0-9a-f]{40}$' || return 1 + + state="$( + release_json "$candidate" 2>/dev/null | + python3 -c 'import json,sys +d=json.load(sys.stdin) +print("%s\t%s\t%s" % ( + str(bool(d.get("draft"))).lower(), + str(bool(d.get("prerelease"))).lower(), + "withdrawn" if "" + end_marker="" + original_body="$(release_field "$VERSION" body)" + block="${begin_marker} +## Withdrawn + +This release has been withdrawn from distribution. + +- Reason: ${REASON} +- Tombstone: \`${TOMBSTONE}\` + +Already-installed clients cannot be remotely downgraded. Install the current ${CHANNEL} channel or a later fixed version. +${end_marker}" + notes="$( + ORIGINAL_BODY="$original_body" WITHDRAW_BLOCK="$block" \ + BEGIN_MARKER="$begin_marker" END_MARKER="$end_marker" python3 - <<'PY' +import os + +body = os.environ["ORIGINAL_BODY"] +block = os.environ["WITHDRAW_BLOCK"] +begin = os.environ["BEGIN_MARKER"] +end = os.environ["END_MARKER"] +if begin in body and end in body and body.index(begin) < body.index(end): + prefix, remainder = body.split(begin, 1) + _, suffix = remainder.split(end, 1) + print(prefix + block + suffix, end="") +else: + separator = "\n\n" if body else "" + print(body + separator + block, end="") +PY + )" + + payload="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-release-json.XXXXXX")" + NOTES="$notes" python3 - "$VERSION" >"$payload" <<'PY' +import json +import os +import sys + +print(json.dumps({ + "name": f"[WITHDRAWN] {sys.argv[1]}", + "body": os.environ["NOTES"], +})) +PY + github_api --method PATCH \ + "repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}" \ + --input "$payload" >/dev/null + rm -f "$payload" + + state="$( + release_json "$VERSION" | + python3 -c 'import json,sys +d=json.load(sys.stdin) +print("%s\t%s\t%s" % ( + d.get("name", ""), + "marked" if "