Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc332133a2 | ||
|
|
e615bd433c | ||
|
|
474ce88d47 | ||
|
|
b35d67b811 | ||
|
|
a14525ed1d | ||
|
|
816c356bbf | ||
|
|
f28dd6ee07 | ||
|
|
765338eb5b | ||
|
|
0bd767a3fe | ||
|
|
42627e769e | ||
|
|
3b22bb4994 | ||
|
|
6c192c2bf4 | ||
|
|
be5ce782b6 | ||
|
|
e1a50f08a6 | ||
|
|
d14ce3c8d2 | ||
|
|
b876b9b4ae | ||
|
|
82c6bcfcbf | ||
|
|
48a79b17c6 | ||
|
|
9b44eeb5b0 | ||
|
|
6de77f4c34 | ||
|
|
00f1379874 | ||
|
|
8687d68567 | ||
|
|
bf74159737 | ||
|
|
3ba0b90f9e | ||
|
|
c2a6ce01aa | ||
|
|
09a300867c | ||
|
|
73e010a992 | ||
|
|
809d026b7e | ||
|
|
3e9e76df2c | ||
|
|
4e59f9aa7a | ||
|
|
047ac54afe | ||
|
|
9a78a6494a | ||
|
|
73bf77d479 | ||
|
|
a98ae9c6cf | ||
|
|
918c73f418 | ||
|
|
ef3c2feafb | ||
|
|
0a7b6d8406 | ||
|
|
b2561388fe | ||
|
|
da30780684 | ||
|
|
96986dfbff | ||
|
|
27c3449036 | ||
|
|
e9cd8c9ad9 | ||
|
|
5856a897d1 | ||
|
|
d0787ce8ee | ||
|
|
363ca3de9b | ||
|
|
fde008a25d | ||
|
|
987273f32b | ||
|
|
32c1d772de | ||
|
|
39b3003e2f | ||
|
|
f423031074 | ||
|
|
2879683cad | ||
|
|
878bafe55d | ||
|
|
114c47b62d | ||
|
|
6d97bf7204 | ||
|
|
06cea56e92 | ||
|
|
1f2b992e9c | ||
|
|
43798de088 | ||
|
|
c4d8987f6c | ||
|
|
fc415919d1 | ||
|
|
125f0c8fe0 | ||
|
|
55afc656ac | ||
|
|
f6c2ce655d | ||
|
|
657d2c25e3 | ||
|
|
9f7107b6bb | ||
|
|
eb5569ca21 | ||
|
|
b7c14c118f | ||
|
|
fb4cf70c93 | ||
|
|
890dfea477 | ||
|
|
bfd48b6a71 | ||
|
|
d41ea586bf | ||
|
|
f77232d7c1 | ||
|
|
31faf7205b | ||
|
|
45e0423d46 | ||
|
|
1b70d8f3f2 | ||
|
|
a6f309d011 | ||
|
|
390b6115bf | ||
|
|
a6b2972a1e | ||
|
|
1e0a171ceb | ||
|
|
cb4d1c215c | ||
|
|
538754bbba | ||
|
|
c2010b912b | ||
|
|
cf8cf95087 | ||
|
|
f86d10ae63 | ||
|
|
3f3ece933b | ||
|
|
3fac462410 | ||
|
|
753866867f | ||
|
|
a62bcdf460 | ||
|
|
561525a18b | ||
|
|
e1ea573247 | ||
|
|
eb9e6be944 | ||
|
|
ec59f7b042 | ||
|
|
63c0b26cf6 | ||
|
|
5004fcd285 | ||
|
|
fc9acb9007 | ||
|
|
aa6abc5ed6 | ||
|
|
eec64bdf35 | ||
|
|
ddad2f648c | ||
|
|
391e761b59 | ||
|
|
a15fb19fd2 | ||
|
|
70107e008f | ||
|
|
b9f2733821 | ||
|
|
83543b20df | ||
|
|
4e4705c673 | ||
|
|
cd652bf9ab | ||
|
|
c5463424be | ||
|
|
15e0851e06 | ||
|
|
f1ca55c649 | ||
|
|
4440479c5c | ||
|
|
5ac5fcbf16 | ||
|
|
4a717bd92f | ||
|
|
604ec5f50a | ||
|
|
27296ec426 | ||
|
|
6a38a168dd | ||
|
|
9771053d81 | ||
|
|
10c0c5083e | ||
|
|
a0187b5297 | ||
|
|
81991f1c07 | ||
|
|
836670ef50 | ||
|
|
53ce0a8303 | ||
|
|
78867f3601 | ||
|
|
37438659e6 | ||
|
|
3c12c835a3 | ||
|
|
389f83241f | ||
|
|
3714adc2db | ||
|
|
f37d0569a1 | ||
|
|
798b58bf3c | ||
|
|
d926bed3cc | ||
|
|
5ac180d3dd | ||
|
|
35e60407d3 | ||
|
|
ea46132cf6 | ||
|
|
478dc155e8 | ||
|
|
08ecb38a42 |
@@ -0,0 +1,95 @@
|
||||
name: AI Behavior Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
ai-behavior-check:
|
||||
name: AI Behavior Policy Evaluator
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Deliberately do not check out or execute pull-request code here.
|
||||
# pull_request_target keeps this policy anchored to the base branch.
|
||||
- name: Check AI-generated PR boundaries
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const sha = context.payload.pull_request.head.sha;
|
||||
const setStatus = (state, description) =>
|
||||
github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state,
|
||||
context: 'AI Behavior Check',
|
||||
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.');
|
||||
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,
|
||||
});
|
||||
|
||||
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/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}).`
|
||||
);
|
||||
+346
-36
@@ -7,8 +7,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -26,7 +25,7 @@ jobs:
|
||||
|
||||
- name: Format Check
|
||||
run: |
|
||||
unformatted="$(find cmd internal test -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
|
||||
|
||||
- name: Go Vet
|
||||
@@ -59,15 +58,150 @@ jobs:
|
||||
run: make build
|
||||
|
||||
- name: Test with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
# The registry-first final-delivery gate validates all public commands
|
||||
# and the complete generated Catalog under the race detector. Keep the
|
||||
# package timeout aligned with the macOS race job so the Linux runner's
|
||||
# five-minute default does not expire while that gate is still making
|
||||
# progress.
|
||||
run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/...
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test macOS auth and Keychain paths with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build Windows CLI
|
||||
run: go build -o dws.exe ./cmd
|
||||
|
||||
- name: Test Windows auth and DPAPI packages
|
||||
run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
|
||||
- name: Test Windows auth migration diagnostics
|
||||
run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
|
||||
coverage-darwin:
|
||||
name: Coverage (macOS)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
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
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run and enforce macOS changed-code coverage
|
||||
run: make coverage-gate-platform BASE_REF="$COVERAGE_BASE_REF" PROFILE=coverage-darwin.txt
|
||||
|
||||
- name: Upload macOS coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-darwin
|
||||
path: coverage-darwin.txt
|
||||
|
||||
coverage-windows:
|
||||
name: Coverage (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
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
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
shell: bash
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run and enforce Windows changed-code coverage
|
||||
shell: bash
|
||||
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
|
||||
|
||||
- name: Upload Windows coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-windows
|
||||
path: coverage-windows.txt
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
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
|
||||
uses: actions/setup-go@v5
|
||||
@@ -80,11 +214,58 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run tests with coverage
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
go test -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./internal/...
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run current and baseline unit tests with coverage
|
||||
run: |
|
||||
set -eu
|
||||
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/...
|
||||
|
||||
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
|
||||
rmdir "$base_worktree"
|
||||
cleanup() {
|
||||
git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
go tool cover -func=coverage.txt
|
||||
|
||||
- name: Enforce coverage gate
|
||||
env:
|
||||
COVERAGE_TARGET: "80"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
- name: Generate coverage report
|
||||
run: go tool cover -html=coverage.txt -o coverage.html
|
||||
|
||||
@@ -94,32 +275,11 @@ jobs:
|
||||
name: coverage-report
|
||||
path: |
|
||||
coverage.txt
|
||||
coverage-base.txt
|
||||
coverage-policy.txt
|
||||
coverage-shortcut.txt
|
||||
coverage.html
|
||||
|
||||
- name: Update coverage badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: ${COVERAGE}%"
|
||||
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
|
||||
COLOR="brightgreen"
|
||||
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
|
||||
COLOR="yellow"
|
||||
else
|
||||
COLOR="red"
|
||||
fi
|
||||
mkdir -p .github/badges
|
||||
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
|
||||
|
||||
- name: Commit badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/badges/coverage.svg || true
|
||||
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
|
||||
git push || true
|
||||
|
||||
policy:
|
||||
name: Policy Check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -139,8 +299,98 @@ jobs:
|
||||
- name: Policy
|
||||
run: make policy
|
||||
|
||||
- name: Generated Drift
|
||||
run: ./scripts/policy/check-generated-drift.sh
|
||||
interface-integrity:
|
||||
name: Interface Integrity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
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
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative compatibility merge-base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
|
||||
if [ -z "$stable_ref" ]; then
|
||||
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
|
||||
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
run: make skill-command-integrity
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
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: Build
|
||||
run: make build
|
||||
|
||||
- name: Check public top-level commands
|
||||
run: make cli-smoke
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP Smoke
|
||||
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 HTTP and stdio MCP transport
|
||||
run: make mock-mcp-smoke
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
@@ -158,11 +408,71 @@ jobs:
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
needs:
|
||||
- lint
|
||||
- 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 }}
|
||||
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" \
|
||||
"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: [test, policy, edition-tests]
|
||||
needs: [ci-gate]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
|
||||
@@ -42,8 +42,7 @@ jobs:
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
VERSION="$GITHUB_REF_NAME" ./scripts/release/sync-gitee-tag.sh
|
||||
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
|
||||
exit 0
|
||||
fi
|
||||
@@ -83,5 +82,7 @@ jobs:
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
# Release tags are immutable. Push only missing tags and fail closed
|
||||
# on a conflicting existing ref instead of trying to move it.
|
||||
timeout --signal=TERM 180s git push --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
|
||||
+178
-15
@@ -18,10 +18,7 @@ jobs:
|
||||
release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
runs-on: ubuntu-latest
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -29,6 +26,19 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check Homebrew PR automation token
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# Organization policy intentionally prevents the broad, built-in
|
||||
# GITHUB_TOKEN from creating PRs. Keep Formula automation on a
|
||||
# repository-scoped token instead of weakening that policy.
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -40,17 +50,50 @@ jobs:
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
- name: Install rcodesign (sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
set -euo pipefail
|
||||
RCS_VERSION="0.29.0"
|
||||
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
printf '%s %s\n' "$RCS_ARCHIVE_SHA256" /tmp/rcodesign.tar.gz \
|
||||
| sha256sum --check --strict -
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Prepare Apple Developer ID certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]; then
|
||||
if [ "$GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI" ]; then
|
||||
echo "APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_PASSWORD are required for official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Developer ID secrets are unavailable; fork release will use ad-hoc signing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
umask 077
|
||||
certificate_path="$RUNNER_TEMP/dws-developer-id.p12"
|
||||
password_path="$RUNNER_TEMP/dws-developer-id-password"
|
||||
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
|
||||
printf '%s' "$APPLE_CERTIFICATE_PASSWORD" > "$password_path"
|
||||
|
||||
# Fail before packaging if the secret is corrupt or the password is wrong.
|
||||
# The exported P12 may use legacy PKCS#12 ciphers; OpenSSL 3 requires
|
||||
# -legacy to validate those containers even though rcodesign can read them.
|
||||
openssl pkcs12 -legacy -in "$certificate_path" -passin "file:$password_path" -noout
|
||||
|
||||
echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV"
|
||||
echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -63,12 +106,116 @@ jobs:
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
||||
DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
- name: Remove Apple Developer ID certificate
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id.p12"
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id-password"
|
||||
|
||||
# GoReleaser uploads the original archives to a Draft before
|
||||
# post-goreleaser.sh replaces the Darwin binaries. Re-upload every changed
|
||||
# file, verify the Draft digests, and keep it private for Apple validation.
|
||||
- name: Upload finalized signed assets to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DWS_PUBLISH_RELEASE: "false"
|
||||
run: ./scripts/release/finalize-github-release.sh
|
||||
|
||||
- name: Preserve finalized distribution files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
verify-darwin-signatures:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Download finalized Darwin assets from Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-darwin-amd64.tar.gz' \
|
||||
--pattern 'dws-darwin-arm64.tar.gz' \
|
||||
--clobber
|
||||
|
||||
- name: Verify finalized Darwin signatures with Apple codesign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
archive="dist/dws-darwin-${arch}.tar.gz"
|
||||
stage="$RUNNER_TEMP/verify-darwin-${arch}"
|
||||
mkdir -p "$stage"
|
||||
tar -xzf "$archive" -C "$stage"
|
||||
test -f "$stage/dws"
|
||||
codesign --verify --strict --verbose=4 "$stage/dws"
|
||||
codesign -dvvv "$stage/dws"
|
||||
done
|
||||
|
||||
publish-release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs:
|
||||
- release
|
||||
- verify-darwin-signatures
|
||||
runs-on: ubuntu-latest
|
||||
# The optional Gitee fallback has its own bounded retry budget and may be
|
||||
# enabled during a cross-border incident. Normal releases skip that step.
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore finalized distribution files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist
|
||||
|
||||
- name: Publish verified Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
|
||||
- name: Open stable Homebrew formula PR
|
||||
# Beta builds must never replace the stable Homebrew formula. Formula
|
||||
# updates use the normal PR path instead of writing main from a tag job.
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
env:
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ github.ref_name }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ github.ref_name }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ github.ref_name }}"
|
||||
|
||||
- name: Open beta Homebrew formula PR
|
||||
# Keep beta in a separately named, keg-only Formula so it cannot replace the
|
||||
# stable dws link for ordinary Homebrew users.
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
env:
|
||||
DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ github.ref_name }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ github.ref_name }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ github.ref_name }}"
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
@@ -105,13 +252,29 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
mirror-gitee-release:
|
||||
# Keep the optional cross-border fallback out of publish-release so all
|
||||
# pre-sync work has an independently provable budget.
|
||||
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
|
||||
needs:
|
||||
- release
|
||||
- publish-release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Restore finalized distribution files
|
||||
uses: actions/download-artifact@v4
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
|
||||
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
|
||||
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 100
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
|
||||
@@ -21,12 +21,16 @@ permissions:
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Each step has its own ceiling. Their 115-minute sum leaves five minutes
|
||||
# for runner scheduling/teardown inside this 120-minute job deadline.
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Download GitHub release assets
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -42,6 +46,7 @@ jobs:
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
timeout-minutes: 100
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
|
||||
+10
@@ -19,6 +19,7 @@ test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
@@ -42,5 +43,14 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
/auto-test/
|
||||
/eval-runs/
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
# Local Go coverage output
|
||||
/coverage.txt
|
||||
/coverage-base.txt
|
||||
/coverage-policy.txt
|
||||
/coverage.html
|
||||
dwsbin
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@ release:
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
draft: false
|
||||
# Keep the release private until post-processing has replaced the Darwin
|
||||
# archives and verified every finalized asset digest.
|
||||
draft: true
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
mode: replace
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# Repository Agent Guide
|
||||
|
||||
This file applies to the entire repository. Keep changes scoped, preserve
|
||||
unrelated work, and use `gofmt` for every modified Go file.
|
||||
|
||||
## Build and test
|
||||
|
||||
- Build: `go build ./cmd`
|
||||
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
|
||||
- Generate Schema assets: `go generate ./internal/cli`
|
||||
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
|
||||
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
|
||||
|
||||
Generated Schema JSON is committed. Change its source inputs and generators,
|
||||
then regenerate; do not hand-edit generated Catalog or Agent metadata files.
|
||||
`internal/cli/schema_command_registry.json` is different: it is a reviewed
|
||||
`CommandRegistry` source, not a generated snapshot. It is the single reviewed
|
||||
source of stable canonical identity,
|
||||
primary paths, aliases, and navigation. Edit it only when reviewed exposure,
|
||||
identity, primary path, or aliases change; parameter, Skill, and metadata-only
|
||||
changes must not rewrite it mechanically.
|
||||
|
||||
## Agent Schema contract
|
||||
|
||||
The Schema data flow is one way:
|
||||
|
||||
```text
|
||||
1. app.NewRootCommand()
|
||||
└─ builds the real Cobra command tree and flags
|
||||
|
||||
2. schema_command_registry.json
|
||||
+ schema_hints/metadata/<product>.json tool parameters (+ cli_path)
|
||||
└─ forms EffectiveCommandRegistry
|
||||
└─ binds exactly to real Cobra leaves and aliases
|
||||
|
||||
3. Parameter resolution
|
||||
Cobra flags
|
||||
+ schema_parameter_bindings.json
|
||||
+ metadata tool parameters
|
||||
└─ produces ParameterSpec and constraints
|
||||
|
||||
4. Agent and interface semantics
|
||||
schema_hints/selection/<product>.json (selection prose)
|
||||
+ schema_hints/metadata/<product>.json (safety/interface/runtime_gate)
|
||||
+ pinned MCP metadata
|
||||
└─ resolves Agent metadata by source precedence
|
||||
Markdown is evidence only; it is not concatenated into final prose
|
||||
|
||||
5. One typed hub
|
||||
BoundCommandRegistry
|
||||
+ ParameterSpec
|
||||
+ Agent metadata
|
||||
+ Interface metadata
|
||||
└─ resolves every command exactly once into ToolSpec
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
|
||||
6. One-way publication
|
||||
SchemaRegistry
|
||||
└─ internal/cli/schema_catalog.json
|
||||
└─ dws schema list/product/group/leaf/--all
|
||||
```
|
||||
|
||||
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
|
||||
*before* Cobra binding; after that point there is no second identity source and
|
||||
no identity precedence winner. The binder must reject a missing/non-runnable
|
||||
Cobra path, an alias collision, and any native identity annotation that
|
||||
disagrees with the effective registry. A missing native identity annotation is
|
||||
allowed because annotations are implementation-side assertions, not identity
|
||||
fallbacks.
|
||||
|
||||
The assembler resolves every bound command exactly once into one `ToolSpec`.
|
||||
Build-time gates and the snapshot serializer consume that source-resolved typed
|
||||
registry/index. Runtime projections and delivery gates consume the typed
|
||||
registry/index returned by the production snapshot loader. Neither path may
|
||||
reopen annotations, merge source records, or use a previous Catalog or other
|
||||
generated JSON as a source. `schema_catalog.json` is output-only in the
|
||||
generation graph. The production loader decoding the embedded published
|
||||
snapshot is a delivery boundary, not source resolution; it must never create or
|
||||
repair a Cobra command, flag, registry entry, or later Catalog generation.
|
||||
|
||||
This split is architecturally isomorphic to Lark's typed metadata registry,
|
||||
navigation catalog, and schema renderer. DWS intentionally preserves its
|
||||
existing flat JSON wire contract for compatibility; do not treat architectural
|
||||
alignment as permission to make an unversioned wire-format change.
|
||||
|
||||
The reviewed `CommandRegistry` is the sole source of stable command identity
|
||||
and navigation. The executable Cobra tree remains the source of truth for
|
||||
whether a CLI path exists, is runnable, and which flags it accepts. Schema
|
||||
coverage is bidirectional:
|
||||
|
||||
1. Every final `SchemaRegistry` tool, including its serialized Catalog
|
||||
projection, must resolve to an executable Cobra command.
|
||||
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
|
||||
an exact, reviewed exclusion with a non-empty reason in
|
||||
`internal/cli/schema_command_exclusions.json`.
|
||||
|
||||
Do not use prefix or wildcard exclusions: they can silently hide future
|
||||
commands. Remove an exclusion when its command enters Schema; stale, invalid,
|
||||
or duplicate exclusions must fail generation and CI.
|
||||
|
||||
When adding or changing an Agent-visible command, review all relevant inputs:
|
||||
|
||||
- `internal/cli/schema_command_registry.json` for the reviewed
|
||||
`CommandRegistry`: canonical identity, primary CLI path, aliases, and stable
|
||||
navigation. It is the identity source and is not a generated artifact.
|
||||
- `internal/cli/schema_command_registry.schema.json` is its closed,
|
||||
machine-readable editing contract. Preserve the local `$schema` reference;
|
||||
unknown fields, invalid visibility values, stale paths, and collisions fail
|
||||
Go validation and policy.
|
||||
- `internal/cli/schema_hints/metadata/<product>.json` for safety, interface,
|
||||
`runtime_gate`, and optional parameter overlays (`parameters` / `cli_path`).
|
||||
- `internal/cli/schema_hints/selection/<product>.json` for reviewed Agent
|
||||
selection prose (`agent_summary`, `use_when`, `avoid_when`, `examples`).
|
||||
- `internal/cli/schema_hints/index.json` only maps product IDs to those files.
|
||||
- Native Runtime Schema identity annotations, when present, as consistency
|
||||
assertions against `EffectiveCommandRegistry`. They must agree exactly and
|
||||
must never materialize, infer, or override registry identity.
|
||||
- Flag-to-interface property mappings and required/default semantics.
|
||||
- Generated files under `internal/cli/schema_agent_metadata/` and
|
||||
`internal/cli/schema_catalog.json` after running generation.
|
||||
|
||||
Run the reverse-completeness tests whenever the Cobra tree changes. A command
|
||||
that works through `dws <path>` but cannot be found through the matching
|
||||
`dws schema` lookup is a contract failure unless it has a reviewed exact
|
||||
exclusion.
|
||||
|
||||
Metadata parameter overlays must reference an exact public runnable Cobra leaf
|
||||
and real flags. They may override Schema description, interface-property/type
|
||||
mapping, `required`, and `required_when`; they must not create commands or
|
||||
flags, define an interface, or advertise an unknown RPC. Every authored entry
|
||||
requires `reviewed: true` and a non-empty review reason.
|
||||
|
||||
For Agent-authored metadata or selection edits:
|
||||
|
||||
1. Confirm the exact command and flag names in the current Cobra tree.
|
||||
2. Edit only the owning block (`metadata/` or `selection/`); do not mix fields.
|
||||
3. Add the smallest possible entry; do not copy generated Catalog fields into
|
||||
the input.
|
||||
4. Describe user-visible semantics in `review_reason` and parameter
|
||||
descriptions.
|
||||
5. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
proposing the change.
|
||||
|
||||
## Agent curation workflow (Schema hints)
|
||||
|
||||
Use this workflow when refreshing Agent selection prose and confirmation
|
||||
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
|
||||
`selection-review.json` or Skill Markdown into Catalog fields.
|
||||
|
||||
Human-authored inputs are split into two blocks:
|
||||
|
||||
| Block | Path | Owns |
|
||||
|---|---|---|
|
||||
| **metadata** | `internal/cli/schema_hints/metadata/<product>.json` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / `runtime_gate` / optional `parameters` |
|
||||
| **selection** | `internal/cli/schema_hints/selection/<product>.json` | `agent_summary` / `use_when` / `avoid_when` / `examples` (+ product routing) |
|
||||
|
||||
`index.json` only maps product IDs to those files. Do not mix selection fields
|
||||
into metadata files or metadata fields into selection files.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
|
||||
sibling-command routing, and outcome shape — not a restatement of the
|
||||
summary. Delivered Catalog provenance is `reviewed_explicit` from
|
||||
`selection/`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` iff the tool's
|
||||
metadata `runtime_gate != none` (for example `confirm_delete`, `typed_yes`,
|
||||
`confirm_dangerous`).
|
||||
3. **Parameter overrides** (former Manual `commands`) live on metadata tools as
|
||||
`parameters` (+ `cli_path`) and are applied into EffectiveCommandRegistry.
|
||||
|
||||
### Authoring
|
||||
|
||||
For every curated tool:
|
||||
|
||||
1. Edit `metadata/<product>.json` for safety/interface/gates/parameters.
|
||||
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
|
||||
`review_reason`, `source_refs`).
|
||||
3. Run `make generate-schema`. Do not hand-edit generated
|
||||
`schema_agent_metadata/` or `schema_catalog.json`.
|
||||
|
||||
### Pull live MCP descriptions (personal token)
|
||||
|
||||
Pinned `internal/cli/schema_mcp_metadata.json` is a sanitized baseline. Prefer
|
||||
live Schema from a logged-in personal session:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # refresh discovery / tools cache
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via `interface_ref` when CLI canonical ≠ MCP path
|
||||
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
|
||||
failure, fall back to Skill + Cobra Help + pinned MCP, and record evidence
|
||||
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
|
||||
|
||||
Precedence when sources disagree: **Runtime/Cobra > live MCP > pinned MCP >
|
||||
Skill (evidence only)**.
|
||||
|
||||
### Parallel product agents
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Hand-write selection + metadata; forbid wholesale JSON merges from review
|
||||
dumps.
|
||||
- Edit only its `metadata/<product>.json` and `selection/<product>.json`.
|
||||
- **Never** `git checkout` unrelated product files to “clean scope”.
|
||||
|
||||
### Regenerate and gates
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
./scripts/policy/check-runtime-confirmation-truth.sh
|
||||
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
|
||||
```
|
||||
|
||||
Example rules (fail generation otherwise):
|
||||
|
||||
- At most two examples per tool; no `--yes` in stored examples.
|
||||
- Examples must match live Cobra argv (path, flags, required groups).
|
||||
- No shell comments in examples.
|
||||
|
||||
After generation, spot-check Catalog: selection provenance is
|
||||
`reviewed_explicit` from `selection/`, and `user_required` count equals
|
||||
metadata `runtime_gate != none`.
|
||||
|
||||
`make generate-schema` is a full deterministic snapshot rebuild, not an
|
||||
incremental patch over the previous Catalog. It rereads every reviewed input,
|
||||
removes stale generated product metadata, and rewrites the exact metadata and
|
||||
Catalog projections. Incremental work happens only when an Agent or human
|
||||
edits selected `metadata/` or `selection/` entries; the next publication still
|
||||
recomputes all outputs. Generated files must never be read back as merge input,
|
||||
and byte guards fail generation if it changes the hint inputs or CommandRegistry.
|
||||
|
||||
Selection prose may choose a more or less restrictive recommendation. It cannot
|
||||
create a Cobra command or flag, change parameter facts, invent an
|
||||
RPC/interface, alter safety metadata, or bypass command completeness. Examples
|
||||
must use an executable primary/alias path and flags accepted by the live Cobra
|
||||
command; never add `--yes` to stored examples.
|
||||
|
||||
Every example is always checked against its real `BoundCommand`: exact path,
|
||||
accepted flags, Cobra required flags/positionals, and the effective
|
||||
`require_one_of`, `require_together`, and `mutually_exclusive` constraints must
|
||||
all pass before execution eligibility is considered. A missing required value,
|
||||
constraint failure, runtime error, or MCP resolution error is a contract bug;
|
||||
none is a valid reason to skip an example.
|
||||
|
||||
Example execution defaults to contract validation only. Runtime execution is
|
||||
opt-in: an example enters `dry_run` only when its final `ToolSpec` publishes an
|
||||
explicit reviewed dry-run capability. The test never injects `--yes`, and
|
||||
`risk`/`confirmation` values do not manufacture preview support. A narrow
|
||||
runtime precondition that cannot be derived from the typed contract may use an
|
||||
exact zero-based `example_dispositions` entry with `mode=contract_only`,
|
||||
`reviewed=true`, one of the schema-enumerated reason codes, and a concrete
|
||||
non-empty reason. Such a disposition may only narrow an explicit dry-run
|
||||
capability; it cannot turn an ordinary contract-only example into a skip.
|
||||
Duplicate, missing, and out-of-range indexes fail validation. Never catch a
|
||||
dry-run failure and dynamically downgrade it to `contract_only`.
|
||||
|
||||
Normal Go tests run the exhaustive contract gate. Run
|
||||
`make test-schema-agent-examples` to additionally execute the eligible subset
|
||||
through the real Cobra `--dry-run` path with isolated HOME and blocked proxies.
|
||||
The test reports stable `total`, `contract`, `dry_run`, `contract_only`,
|
||||
`reviewed_manual`, and per-reason counts; changing those counts requires a
|
||||
review of the corresponding typed dry-run capability or manual disposition.
|
||||
This target is also part of `make policy`.
|
||||
|
||||
Treat every tool `use_when` entry as a reviewed positive selection scenario
|
||||
whose expected result is that tool's canonical path, and every `avoid_when`
|
||||
entry as a reviewed negative scenario that must not choose that tool. The
|
||||
deterministic gate derives a typed evaluation fixture from these same fields;
|
||||
it requires exact tool coverage, a real runnable `BoundCommandRegistry`
|
||||
primary command, at least one positive and negative assertion per tool, and no
|
||||
literal contradictory expectations. It does not claim that string matching
|
||||
proves natural-language understanding.
|
||||
|
||||
Semantic selection is an explicit opt-in live-model check. Run the smoke set
|
||||
(one positive and one negative scenario per product) with
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestManualAgentSelectionArkLive -count=1`.
|
||||
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
|
||||
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
|
||||
never calls a model; its blockers remain the reproducible fixture, binding,
|
||||
example, provenance, and final-delivery facts.
|
||||
|
||||
The live evaluator sends only case IDs/scenarios plus one same-product
|
||||
candidate table; expected/forbidden assertions stay local and must never be
|
||||
included in the model prompt. Built-in Ark HTTPS bases are allowlisted. A
|
||||
different HTTPS provider requires its exact base in
|
||||
`DWS_AGENT_SELECTION_ALLOWED_BASE_URLS`; plaintext HTTP is accepted only for a
|
||||
loopback test server so API credentials are never sent to an arbitrary clear
|
||||
text endpoint.
|
||||
|
||||
## Safety metadata
|
||||
|
||||
Parameter and safety resolution is mostly source-precedence based and
|
||||
value-neutral: do not choose a winner because one value looks stricter. A
|
||||
higher-priority reviewed metadata/explicit source may intentionally raise or
|
||||
lower description, mapping, `effect`, `risk`, `confirmation`, or `idempotency`.
|
||||
Preserve all candidates and the selected source in provenance, and fail
|
||||
same-precedence conflicts rather than silently merging them.
|
||||
|
||||
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
|
||||
final Agent projection must keep `required=true` and cannot be lowered by
|
||||
manual/hint overlays. Overlays may still raise an optional flag to required.
|
||||
`cli_required` continues to mirror the executable Cobra marker.
|
||||
|
||||
For command text, reviewed `ToolSchemaHint` wins first, then command-specific
|
||||
Cobra Help, then MCP metadata. Generic RPC prose may remain an unselected
|
||||
provenance candidate (and parameter-level `interface_description`); it must not
|
||||
overwrite a specialized leaf's title or description.
|
||||
|
||||
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
|
||||
winner value must exactly equal the delivered value. Checking only source,
|
||||
count, presence, or hash is not a sufficient final-delivery invariant.
|
||||
|
||||
The same resolved `ToolSpec` must drive every projection. The full leaf payload
|
||||
must equal the corresponding tool in `schema --all` and the full Catalog tool.
|
||||
Overview/product/group summaries and Catalog summaries must equal
|
||||
`ToolSpec.ToSummaryPayload()`. An alias lookup may change only the view fields
|
||||
`cli_path` and `is_alias`; it must not re-resolve or mutate the command
|
||||
contract.
|
||||
|
||||
This build-time rule is distinct from runtime drift handling. If shipped Help
|
||||
and leaf Schema disagree, pass only flags accepted by Cobra. For conflicting
|
||||
safety information, do not silently take the less restrictive behavior: use
|
||||
the safer interpretation or stop and report the contract drift.
|
||||
|
||||
Do not infer one safety field from another. In particular, `effect=destructive`
|
||||
or `risk=high` does not mechanically rewrite `confirmation`; the final
|
||||
precedence winner for each field is authoritative. When
|
||||
`confirmation=user_required`, obtain confirmation before adding `--yes`.
|
||||
Keep CLI confirmation behavior and Schema metadata consistent, and add a
|
||||
semantic regression test through the final embedded loader/query delivery
|
||||
path; a generator unit test or JSON count alone is insufficient.
|
||||
|
||||
## Current Schema boundaries
|
||||
|
||||
- `schema list` remains a progressive overview. `schema --all` is the stable
|
||||
full-export contract: every final `SchemaIndex` tool must contain its
|
||||
complete leaf parameters, constraints, and safety semantics, including an empty
|
||||
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||
compatibility baseline and fail rather than silently emitting a partial
|
||||
export.
|
||||
- `schema --all` is not normal command discovery. Use overview -> product/group
|
||||
-> leaf for routine Agent work. `--compact` is supported for context-saving
|
||||
projections, but a compact full export is not a complete compatibility
|
||||
baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A leaf Schema defines Agent selection, parameter mapping
|
||||
and constraints, and safety/confirmation semantics. A conflict is contract
|
||||
drift, not permission to guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
@@ -6,6 +6,42 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Declarative shortcut commands** (#592) — adds 366 `dws <service> +<command>` shortcuts across 16 services, including one-to-one MCP wrappers and multi-step smart workflows. Shortcuts publish stable Agent-visible contracts with named flags, validation and confirmation metadata, dry-run protection for writes, catalog/help routing, and optional local YAML extensions and usage recording.
|
||||
- **Sheet imports and Aitable workflow writes** (#624) — adds `dws sheet import` / `sheet import create` for converting local xlsx/xls files into new online sheets, `sheet import get` for polling import tasks, and `dws aitable workflow create/update` for applying validated `workflow-dsl/v1` definitions, with matching reviewed Agent Schema and bundled Skill guidance.
|
||||
- **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sheet and Todo invalid-target failures** — `sheet range read/get` now rejects a null cell-info response instead of printing `null` and exiting successfully, while Todo completion and attachment listing verify that a task exists before calling lenient backend endpoints. Attachment listing is also published through Runtime Schema for schema-first Agent discovery.
|
||||
|
||||
## [1.0.52] - 2026-07-14
|
||||
|
||||
This release seals the `v1.0.52` line with personal event subscriptions, a deterministic 22-product Agent command catalog, local user-operation auditing, expanded Open product commands, safer macOS credentials and release signing, and more reliable Connect and IM delivery.
|
||||
|
||||
### Added
|
||||
|
||||
- **Personal event subscriptions** (#589) — adds `dws event list/schema/consume/status/stop` for user @ mentions, selected one-to-one chats, and selected group chats. `consume` can create or reuse a personal subscription, multiple local consumers share one bus while keeping outputs isolated by event type and subscription, and the mono/multi event Skills ship with the binary.
|
||||
- **Open product command capabilities** (#608) — adds Sheet table, pivot-table, and gridline commands; Chat message favorites; Drive statistics and shortcuts; and Doc comment update/delete, with matching mono/multi Skill documentation and command-contract coverage.
|
||||
- **Local user-operation audit log** (#555) — operations executed through `dws` now produce redacted daily JSONL records with actor, command and endpoint, result or error category, duration, CLI/platform metadata, and a SHA-256 previous-hash chain for tamper evidence. Writers coordinate through a cross-process file lock and rotate logs safely; `dws audit tail` inspects recent records, `dws audit export` emits date-filtered JSONL or CSV, and `dws audit verify` reports the first broken link in a file's hash chain.
|
||||
- **Stable Agent command catalog** (#598) — `dws schema` now ships a deterministic 22-product / 564-tool catalog generated from the executable Cobra tree, with progressive product/group/leaf queries, complete parameter contracts, reviewed command identity and aliases, safety/confirmation metadata, field provenance, and final-delivery completeness/drift gates. The catalog is embedded at build time and does not require runtime MCP `tools/list` discovery.
|
||||
- **Reviewed Schema for local commands** (#598, #609) — `event consume/list/schema/status/stop` and `audit export/tail/verify` enter the reviewed `CommandRegistry`, bind to the real Cobra tree at generation time, and ship through the same typed `ToolSpec` and embedded Catalog path as public MCP-backed commands. Leaf, group, product, and `--all` queries are projections of that single delivered model.
|
||||
- **Safe macOS Keychain → file-DEK migration** (#597) — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`event consume` AI-subprocess contract** (#609) — emits a fixed ready line and a final controlled-exit summary, supports parent-pipe stdin EOF as graceful shutdown, forwards `--profile` to the detached bus, surfaces bus startup errors, and cleans up subscriptions according to ownership so orchestrators can drive event streams without sleeps or leaked server-side subscriptions.
|
||||
- **Wukong IM read-result parity** (#618) — `chat message list` preserves quoted merged-forward and image context; message-search entitlement failures retain the server-provided friendly hint and action URL; and `ding message list` exposes each DING's content alongside its ID and status.
|
||||
- **Developer ID signing for official macOS archives** (#605) — official releases now require both Darwin archives to be signed with the configured Apple Developer ID certificate, timestamp, and hardened runtime. The release job validates credentials and signatures and fails closed instead of silently publishing ad-hoc-signed official binaries.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Smart-category mappings and runtime network diagnostics** (#591) — `chat category create-smart` now maps category names, group-name keywords, and member OpenDingTalk IDs to the live MCP contract, rejects blank or empty supplied values locally, and reports runtime `tools/call` connection failures as actionable API/network errors instead of internal discovery failures.
|
||||
- **Connect daemon restart lifecycle** (#599) — pins the Stream SDK reconnect-race fix, snapshots the running executable before detaching, uses a real 30-second keepalive, and manages each worker as its own Unix process group so launcher cleanup or worker panics no longer cause restart loops or orphan local-agent processes.
|
||||
- **Complex Connect messages and attachments** (#606, #612) — rich-text messages retain all embedded pictures in order, queued turns keep every pending attachment, and unknown or future callback shapes reach each Agent backend with their message type and raw JSON instead of being discarded. Attachment recovery is locator-based, nested `chatRecord` pictures/audio/video/files can be recovered from message APIs after Stream ACK, and OpenCode uses a full-duration storyboard for large videos to avoid base64 OOMs while preserving the original download for the turn.
|
||||
- **macOS auth survives Keychain mode changes** (#597) — credential reads try existing compatible DEKs without creating key material, updates preserve the DEK that decrypted existing ciphertext, unreadable slots fail closed before token exchange, profile slots use the canonical auth backend, and `auth status` reports ciphertext/key mismatches instead of treating them as ordinary logout. Dedicated macOS race and Windows DPAPI coverage protect the cross-platform paths.
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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.52-beta.5"
|
||||
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.52-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7164f2b0389ce0c3bc1d745b5c98082c1ef92c8547c9b123dcb4e83fe172f92e"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "6ebd48fb96009cf2a81eb0af15216ba050620db55470d5c9937467aa66558879"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "5f718244665c33a9327130874788d0fad36824ec29eb437ab82aa83e3d5a0579"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "e79abccc1e093b946be89282bd034ba60ab479cc8ee1a51001eb0d441c66125c"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-skills.zip"
|
||||
sha256 "64c48271de89a94f9c184a475692e0e2f5e23bc0480c10824f717b21e3a83097"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
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"
|
||||
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"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
|
||||
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
|
||||
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"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
|
||||
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
|
||||
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"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,9 @@
|
||||
GO ?= go
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test 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 generate-schema-catalog package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -10,7 +13,21 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make policy - Run open-source asset and command-surface checks\n"
|
||||
@printf " make policy - 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=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform 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=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
|
||||
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
|
||||
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
|
||||
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
|
||||
@printf " make release - Build and publish a release via goreleaser\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
@@ -28,15 +45,89 @@ lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
fmt:
|
||||
@find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
|
||||
policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
|
||||
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@./scripts/policy/check-interface-baseline.sh
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
|
||||
coverage-gate-platform:
|
||||
@./scripts/policy/run-platform-coverage-gate.sh --base-ref "$(BASE_REF)" --profile "$(PROFILE)"
|
||||
|
||||
update-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --update
|
||||
|
||||
reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
mock-mcp-smoke:
|
||||
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
|
||||
|
||||
generate-schema:
|
||||
@set -e; \
|
||||
registry_guard=$$(mktemp); \
|
||||
metadata_guard=$$(mktemp -d); \
|
||||
selection_guard=$$(mktemp -d); \
|
||||
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
|
||||
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
|
||||
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/selection "$$selection_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/selection' >&2; \
|
||||
exit 1; \
|
||||
}
|
||||
|
||||
generate-schema-agent-metadata:
|
||||
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
|
||||
-root . \
|
||||
-registry internal/cli/schema_command_registry.json \
|
||||
-output-dir internal/cli/schema_agent_metadata \
|
||||
-audit-output internal/cli/schema_agent_metadata_audit.json
|
||||
|
||||
generate-schema-catalog:
|
||||
$(GO) run -a ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output internal/cli/schema_catalog.json
|
||||
|
||||
package:
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 22 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 22 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -93,6 +93,30 @@ How to pick:
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
Install the latest beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> The Formula lives in this repository, so the first `tap` command must include the explicit repository URL. Afterwards, use `brew upgrade dingtalk-workspace-cli` normally.
|
||||
|
||||
Install the keg-only Homebrew beta without replacing the stable Formula:
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
To make the beta `dws` the default for the current shell, prepend `$(brew --prefix dingtalk-workspace-cli-beta)/bin` to PATH.
|
||||
|
||||
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
|
||||
|
||||
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
|
||||
@@ -168,6 +192,18 @@ dws upgrade -y # skip confirmation prompt
|
||||
|
||||
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
|
||||
|
||||
### Six-channel post-release verification
|
||||
|
||||
Maintainers and release validators can run the release-quality smoke checks for curl, PowerShell, npm stable, npm beta, Homebrew, and `dws upgrade`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
The verifier uses isolated directories and does not replace the `dws` on the current PATH. It reports `PASS`, `FAIL`, and `SKIP`; a platform skip is not a pass and must be covered on the matching host. See [`verify/README.md`](verify/README.md) for the platform matrix.
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
@@ -258,6 +294,16 @@ dws --profile <name|corpId> contact user search --query "..." # run one comman
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -313,25 +359,35 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
|
||||
### Command Help and Schema
|
||||
|
||||
Product commands are compiled into the binary in static endpoint mode. Use `--help` and the bundled Agent Skills as the source of truth; `dws schema` is retained for helper-only schemas such as `dev.*`.
|
||||
Use Cobra help and Schema for different parts of the command contract:
|
||||
|
||||
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
|
||||
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
|
||||
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
|
||||
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
|
||||
|
||||
```bash
|
||||
# Inspect the current compiled command surface
|
||||
# Confirm that the command exists and inspect accepted flags
|
||||
dws aitable record query --help
|
||||
|
||||
# Helper-only schema introspection
|
||||
dws schema "dev app create"
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
|
||||
# Construct the call
|
||||
# Execute the real business query
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 22 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
Shared reviewed inputs for Schema generation live separately under `internal/cli/schema_hints/`. They are not Agent Skills and are excluded from binaries and release skill bundles.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
@@ -406,6 +462,51 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
|
||||
|
||||
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
|
||||
|
||||
For an event-focused installation, use the official convenience installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
|
||||
| Shared connection | Consumers for the same user share one local bus and cloud connection |
|
||||
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
|
||||
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
|
||||
|
||||
@@ -496,12 +597,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema Introspection</strong> — helper-only schemas in static endpoint mode</summary>
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
|
||||
```bash
|
||||
dws schema # static endpoint mode note
|
||||
dws schema "dev app create" # view helper-only schema
|
||||
dws schema "dev app create" --jq '.tool.required' # view required fields
|
||||
dws schema aitable # discover product commands
|
||||
dws schema "aitable record query" # view the selected leaf contract
|
||||
dws schema "aitable record query" --jq '.tool.required' # view required fields
|
||||
dws schema --all # full export for CI/audit/baselines
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
+115
-13
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 22 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。22 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -93,6 +93,30 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
安装最新 beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew**(macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> Formula 与代码位于同一个仓库,因此首次 `tap` 需要显式指定仓库 URL。后续可直接使用 `brew upgrade dingtalk-workspace-cli`。
|
||||
|
||||
安装 Homebrew beta(keg-only,不覆盖稳定版):
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
如需让 beta 的 `dws` 成为当前 shell 默认版本,将 `$(brew --prefix dingtalk-workspace-cli-beta)/bin` 放到 PATH 最前面。
|
||||
|
||||
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
|
||||
|
||||
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
|
||||
@@ -165,6 +189,18 @@ dws upgrade -y # 跳过确认直接升级
|
||||
|
||||
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
|
||||
|
||||
### 六渠道发布后验证
|
||||
|
||||
维护者和验证同学可按发版质量保障 SOP,对 curl、PowerShell、npm stable、npm beta、Homebrew、`dws upgrade` 执行安装与冒烟验证:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
脚本使用隔离目录,不会替换当前 PATH 中的 `dws`;输出 `PASS`、`FAIL`、`SKIP` 汇总。跨平台渠道必须由对应平台补测,`SKIP` 不计为通过。验证范围和平台矩阵见 [`verify/README.md`](verify/README.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
@@ -255,6 +291,16 @@ dws --profile <名称|corpId> contact user search --query "..." # 单次对指
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -310,25 +356,35 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
|
||||
### 命令帮助与 Schema
|
||||
|
||||
产品命令在静态端点模式下已经编译进二进制。Agent 以 `--help` 和内置 Skill 为事实源;`dws schema` 仅保留给 `dev.*` 等 helper-only schema 查询。
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
|
||||
```bash
|
||||
# 查看当前编译出的命令面
|
||||
# 确认命令存在并查看当前接受的 flags
|
||||
dws aitable record query --help
|
||||
|
||||
# helper-only schema 自省
|
||||
dws schema "dev app create"
|
||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
|
||||
# 构造正确的调用
|
||||
# 执行真实业务查询
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 22 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
Schema 生成共享的 reviewed 输入单独位于 `internal/cli/schema_hints/`。它们不是 Agent Skill,也不会进入二进制或发布 skill 包。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
@@ -403,6 +459,51 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
|
||||
## 功能特性
|
||||
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
|
||||
|
||||
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
|
||||
|
||||
只需要 event 能力时,可以使用官方便捷安装脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
|
||||
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
|
||||
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
|
||||
|
||||
@@ -493,12 +594,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema 自省</strong> — 静态端点模式下的 helper-only schema</summary>
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
|
||||
```bash
|
||||
dws schema # 静态端点模式提示
|
||||
dws schema "dev app create" # 查看 helper-only schema
|
||||
dws schema "dev app create" --jq '.tool.required' # 查看必填字段
|
||||
dws schema aitable # 发现产品命令
|
||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -1,193 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -1,8 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -1,54 +0,0 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -1,143 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -1,133 +0,0 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -1,58 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -0,0 +1,63 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "__DESCRIPTION__"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "__VERSION__"
|
||||
license "Apache-2.0"
|
||||
__KEG_ONLY_LINE__
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "__DARWIN_ARM64_URL__"
|
||||
sha256 "__DARWIN_ARM64_SHA256__"
|
||||
else
|
||||
url "__DARWIN_AMD64_URL__"
|
||||
sha256 "__DARWIN_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "__LINUX_ARM64_URL__"
|
||||
sha256 "__LINUX_ARM64_SHA256__"
|
||||
else
|
||||
url "__LINUX_AMD64_URL__"
|
||||
sha256 "__LINUX_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "__SKILLS_URL__"
|
||||
sha256 "__SKILLS_SHA256__"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
__CHANNEL_CAVEAT__
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
+7
-38
@@ -1,5 +1,5 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "DingTalk Workspace CLI"
|
||||
desc "Install locally built DingTalk workspace CLI artifacts for verification"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
url "__ARCHIVE_URL__"
|
||||
sha256 "__ARCHIVE_SHA256__"
|
||||
@@ -12,8 +12,6 @@ __KEG_ONLY_LINE__
|
||||
end
|
||||
|
||||
def install
|
||||
require "fileutils"
|
||||
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
@@ -28,44 +26,15 @@ __KEG_ONLY_LINE__
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
FileUtils.cp_r(Dir["*"], skill_dest)
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def post_install
|
||||
require "fileutils"
|
||||
|
||||
skill_root = pkgshare/"skills/dws"
|
||||
entries = Dir["#{skill_root}/*"]
|
||||
return if entries.empty?
|
||||
|
||||
targets = [
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
parent_gate = dest.parent.parent
|
||||
next if index > 0 && !parent_gate.directory?
|
||||
|
||||
FileUtils.rm_rf(dest)
|
||||
FileUtils.mkdir_p(dest)
|
||||
FileUtils.cp_r(entries, dest)
|
||||
end
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
# Architecture
|
||||
|
||||
`dws` is a Go CLI that turns DingTalk MCP metadata into a command-line surface for both humans and AI agents.
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; the embedded Command Catalog serves AI agents.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
|
||||
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`), product helper commands, and plugin commands.
|
||||
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`), product helpers, and versioned plugin descriptors.
|
||||
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
|
||||
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
|
||||
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
|
||||
6. Schema generation starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, Agent hints, and Skills into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`.
|
||||
7. The embedded Catalog is a downstream release artifact and never backfills identity or participates in regeneration. Stable flag-to-interface property bindings come from the reviewed, content-addressed v3 manifest in `schema_parameter_bindings.json`; its exact active tuples, corrections, removals, and mapping exclusions are validated against the final bound `SchemaRegistry`. CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
8. Agent selection results are fixed in versioned review inputs. Every public tool has explicit use/avoid/example and interface disposition metadata; Skill references that are not current leaves require an explicit alias/group/stale/out-of-surface review instead of fuzzy runtime matching.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `cmd`: CLI entrypoint
|
||||
- `internal/app`: root command wiring, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: plugin-based dynamic command loader
|
||||
- `internal/cli`: catalog types and endpoint loader (static endpoint mode)
|
||||
- `internal/plugin`: versioned plugin manifest, hook, skill, and transport descriptor loading
|
||||
- `internal/cli`: embedded Agent Command Catalog, static schema query, and catalog contracts
|
||||
- `internal/generator`: deterministic Agent metadata and Command Catalog generators
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection, identity
|
||||
|
||||
@@ -62,6 +62,24 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Homebrew Formula PR Automation
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
|
||||
allow fine-grained personal access tokens to target this repository, so use a
|
||||
classic personal access token owned by a maintainer or release-bot account with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
|
||||
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
|
||||
secret. Replace it immediately if it is exposed, its owner loses repository
|
||||
access, or the release-bot ownership changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
Before handoff, include:
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Pull request quality gates
|
||||
|
||||
The repository defines five focused checks in addition to its existing CI:
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Running the compatibility gates
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
make build
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
# Run on the corresponding native runner with its generated profile:
|
||||
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
|
||||
```
|
||||
|
||||
`make coverage-gate` is the enforcement step, not a profile generator. It
|
||||
expects the candidate, policy, and merge-base profiles (`coverage.txt`,
|
||||
`coverage-policy.txt`, and `coverage-base.txt`) produced by the preceding CI
|
||||
steps. A clean local checkout can reproduce the Linux/overall CI gate with:
|
||||
|
||||
```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 -coverprofile=coverage.txt -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
|
||||
./pkg/... ./scripts/policy/...
|
||||
git worktree add --detach "$base_worktree" "$base_ref"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 -coverprofile="$root/coverage-base.txt" -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
make coverage-gate BASE_REF="$base_ref"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
- `CI Gate`
|
||||
- `Multi Profile E2E`
|
||||
- `AI Behavior Check`
|
||||
|
||||
`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.
|
||||
@@ -147,8 +147,8 @@ _Group chats, conversations, messages, and robot/webhook integrations._
|
||||
| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. |
|
||||
| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. |
|
||||
| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. |
|
||||
| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. |
|
||||
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
|
||||
| `dws chat message list` | Pull the recent message history of a specific conversation, including quoted-message context for merged forwards and images. | When the agent needs to read what has recently been said in a conversation and retain the context of replies. |
|
||||
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range, surfacing any search-entitlement guidance. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
|
||||
| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. |
|
||||
| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. |
|
||||
| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. |
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Event consume — AI subprocess contract
|
||||
|
||||
Aligns `dws event consume` with the "AI subprocess contract" that
|
||||
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
|
||||
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
|
||||
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
|
||||
exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
## Baseline (already present, no work)
|
||||
|
||||
- `--max-events N` — stop after N events (exit 0).
|
||||
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
|
||||
aliased to `--timeout`: the global `--timeout` is the HTTP request
|
||||
timeout (int seconds) and would collide (different type and meaning).
|
||||
Docs note the lark-cli name difference.
|
||||
- Bus idle-shutdown fires only with **zero** consumers, so a connected
|
||||
consumer is never idle-killed.
|
||||
- SIGINT/SIGTERM already cancel the run context and return cleanly.
|
||||
|
||||
## Improvements
|
||||
|
||||
### 1. Ready marker (standardized)
|
||||
|
||||
On connect, emit a fixed stderr line **before** any stdout event:
|
||||
|
||||
```
|
||||
[event] ready event_key=<key> bus_pid=<pid>
|
||||
```
|
||||
|
||||
Parents block on stderr until this line, then read stdout. Suppressed
|
||||
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
|
||||
omits `event_key`).
|
||||
|
||||
**Verification**
|
||||
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
|
||||
- T1b: that line appears before the first stdout event (ordering).
|
||||
- T1c: with `--quiet`, the line is absent.
|
||||
|
||||
### 2. stdin EOF = graceful exit
|
||||
|
||||
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
|
||||
subprocess callers). To stay resident, feed a never-EOF stdin
|
||||
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
|
||||
|
||||
**Verification**
|
||||
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
|
||||
line `reason: signal` (stdin-eof classified as signal).
|
||||
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
|
||||
5s, connection intact.
|
||||
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
|
||||
via the cleanup path.
|
||||
|
||||
### 3. Exit reason contract + exit codes
|
||||
|
||||
On exit, final stderr line:
|
||||
|
||||
```
|
||||
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
|
||||
```
|
||||
|
||||
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
|
||||
or runtime failure (permissions, network, params) = non-zero, with no
|
||||
`exited` line and an `Error:` line instead.
|
||||
|
||||
**Verification**
|
||||
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
|
||||
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
|
||||
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
|
||||
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
|
||||
- Unit tests assert (reason string, exit code) for each path.
|
||||
|
||||
### 4. Cleanup on exit (no `kill -9`)
|
||||
|
||||
Ownership-based, matching lark-cli:
|
||||
- If this run **created** the subscription (no `--subscribe-id`), a clean
|
||||
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
|
||||
it server-side and sends Bye.
|
||||
- If `--subscribe-id` was passed (reusing an existing subscription), the
|
||||
subscription is **left intact** — the caller owns its lifecycle.
|
||||
- `--ephemeral` remains as an explicit "always unsubscribe" override.
|
||||
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
|
||||
server-side subscription: "subscription already exists" on restart,
|
||||
duplicate delivery). Prefer SIGTERM or closing stdin.
|
||||
|
||||
**Verification**
|
||||
- T4a: start consume (self-created subscription), record subscribe_id;
|
||||
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
|
||||
and the server-side subscription is gone.
|
||||
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
|
||||
subscription is still present (reuse case preserved).
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
|
||||
down and takes consume with it (the likely cause of the observed silent
|
||||
drop). Making more drops retryable, keeping the bus alive across a
|
||||
reconnect, and emitting `reason: source_lost` only after exhausting the
|
||||
budget — tracked on its own branch, since it needs error-classification
|
||||
judgement and real flaky-network testing, and would otherwise couple clean
|
||||
contract work with resilience work.
|
||||
|
||||
## Test surface
|
||||
|
||||
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
|
||||
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
|
||||
- Integration/e2e: `--foreground` + mock source (or a short real run) for
|
||||
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
|
||||
codes.
|
||||
+38
-19
@@ -34,7 +34,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty "dev app create" # Pretty helper-only schema view / helper-only schema 彩色分区展示
|
||||
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -51,40 +51,59 @@ dws contact user search --query "Alice" -o result.json
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
静态端点模式下,产品命令和 flag 以当前二进制的 `--help` 与内置 Skill 为准。`dws schema` 仅保留 helper-only 子树(如 `dev.*`)的 schema 查询。
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本内嵌的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 来自 reviewed `CommandRegistry`,并在发布时逐项绑定当前 Cobra tree。编辑 `internal/cli/schema_command_registry.json` 时必须遵守同目录的 `schema_command_registry.schema.json`;普通生成流程只校验该 reviewed input,不会覆盖它。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
|
||||
### 路径写法
|
||||
|
||||
```bash
|
||||
dws schema # 静态端点模式提示
|
||||
dws schema "dev app create" # CLI 空格路径
|
||||
dws schema --cli-path "dev app create" # 显式 flag(脚本友好,免转义)
|
||||
dws schema -f pretty "dev app create" # ANSI 着色分区展示(人肉查看最舒服)
|
||||
dws schema # 当前公开产品面的紧凑概览
|
||||
dws schema calendar # 展开一个产品
|
||||
dws schema "calendar event" # 展开一个命令分组
|
||||
dws schema "calendar event create" # 按 CLI 空格路径查询工具
|
||||
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
|
||||
dws schema --cli-path "calendar event create" # 显式 CLI path
|
||||
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
|
||||
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
||||
```
|
||||
|
||||
helper-only schema 以 CLI 路径为准;普通产品命令请使用 `dws <path> --help` 查看参数。
|
||||
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
|
||||
|
||||
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
|
||||
|
||||
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
|
||||
|
||||
### Schema、Help 与业务数据的边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|------|--------|
|
||||
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
|
||||
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
||||
|
||||
### 单工具输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / helper-only canonical path |
|
||||
| `group` | CLI 父级 group 路径(dot-separated) |
|
||||
| `title` / `description` | 工具名/说明(overlay 优先) |
|
||||
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
|
||||
| `output_schema` | MCP 输出 Schema(上游下发时才有) |
|
||||
| `sensitive` | 敏感写操作,需 `--yes` 确认 |
|
||||
| `auth` | DingTalk 授权元数据,包括 `requiredScopes` / `requiredPermissions` / `recommendedScopes` / `grantProductCodes` / `riskAction` / `confirmationRequired` |
|
||||
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
|
||||
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
|
||||
| `canonical_path` / `primary_cli_path` / `aliases` | 稳定工具 ID、主 CLI 路径和兼容路径 |
|
||||
| `product_id` / `interface_ref` | CLI 产品与实际 MCP product/RPC binding |
|
||||
| `title` / `description` / `agent_summary` | 人类说明、接口说明和 Agent 摘要 |
|
||||
| `parameters.<flag>` | CLI flag 的类型、属性名、required、默认值、格式、枚举和条件必填 |
|
||||
| `constraints` | one-of、互斥、联动等组合约束 |
|
||||
| `effect` / `risk` / `confirmation` / `idempotency` | Agent 执行与安全策略 |
|
||||
| `use_when` / `avoid_when` / `examples` | Agent 选择提示和示例 |
|
||||
| `reviewed` / `agent_source_refs` | 语义审核状态与来源追踪 |
|
||||
|
||||
**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
|
||||
`parameters.<flag>.required` 是按来源 precedence 解析后的 Agent 参数契约;`cli_required=true` 才表示 Cobra 将该 flag 标记为硬必填。条件必填或别名选择通过 `required_when` 和 `constraints.require_one_of` 表达。`required` 不直接复制 MCP input schema,也不取代 Cobra 的实际执行校验。
|
||||
|
||||
### 筛选输出
|
||||
|
||||
```bash
|
||||
dws schema "dev app create" --jq '.tool.parameters' # 只看参数 schema
|
||||
dws schema "dev app create" --jq '.tool.required' # 只看必填字段
|
||||
dws schema "calendar event create" --jq '.parameters' # 只看参数
|
||||
dws schema "calendar event create" --jq '[.parameters | to_entries[] | select(.value.required)]' # 只看 Agent required 参数
|
||||
```
|
||||
|
||||
## Shell Completion / 自动补全
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# DWS Agent Schema 统一方案
|
||||
|
||||
## 1. 核心定义
|
||||
|
||||
DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描述真实 Cobra 命令,并补充 Agent 选择、参数映射、组合约束、安全确认和接口事实。
|
||||
|
||||
设计遵循三条硬规则:
|
||||
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
|
||||
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
|
||||
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
|
||||
|
||||
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
|
||||
|
||||
## 2. 单向数据流
|
||||
|
||||
```text
|
||||
schema_command_registry.json (reviewed CommandRegistry source)
|
||||
+ reviewed manual command additions
|
||||
|
|
||||
v
|
||||
EffectiveCommandRegistry
|
||||
|
|
||||
v
|
||||
exact binder to live Cobra tree
|
||||
+ native identity consistency assertions
|
||||
|
|
||||
v
|
||||
BoundCommandRegistry
|
||||
|
|
||||
+----------------------+
|
||||
|
|
||||
skills/mono Markdown + internal/cli/schema_hints/*.json |
|
||||
+ schema_mcp_metadata.json |
|
||||
| |
|
||||
v |
|
||||
Agent-metadata normalization |
|
||||
| |
|
||||
v |
|
||||
schema_agent_metadata/*.json |
|
||||
(generated normalized input) |
|
||||
| |
|
||||
+-----------------------+
|
||||
|
|
||||
live Cobra flag facts / typed parameter metadata
|
||||
+ schema_hints/metadata/*.json (reviewed parameter overlay + safety)
|
||||
+ schema_hints/selection/*.json (reviewed Agent selection prose)
|
||||
+ schema_parameter_bindings.json (reviewed flag -> RPC property)
|
||||
+ schema_mcp_metadata.json (pinned, sanitized interface facts)
|
||||
+ normalized Agent metadata
|
||||
|
|
||||
v
|
||||
source adapters + resolvers
|
||||
|
|
||||
v
|
||||
one typed SchemaRegistry
|
||||
(one ToolSpec per command)
|
||||
+
|
||||
typed SchemaIndex
|
||||
+-----------+-----------+
|
||||
| |
|
||||
v v
|
||||
build-time typed gates snapshot serializer
|
||||
|
|
||||
v
|
||||
schema_catalog.json
|
||||
(release output only)
|
||||
|
|
||||
v
|
||||
go:embed -> typed loader
|
||||
|
|
||||
v
|
||||
SchemaRegistry + SchemaIndex
|
||||
|
|
||||
+---------------------+------------------+
|
||||
| | |
|
||||
overview/product/group leaf --all
|
||||
projections projection full projection
|
||||
| | |
|
||||
+---------------------+------------------+
|
||||
|
|
||||
v
|
||||
runtime query + delivery gates
|
||||
```
|
||||
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、manual hint 或生成 JSON 重新解析 command identity。
|
||||
|
||||
## 3. 与 Lark 的关系
|
||||
|
||||
DWS 与 Lark 保持**架构同构**,而不是强行复制字段:
|
||||
|
||||
| Lark 分层 | DWS 对应层 |
|
||||
|---|---|
|
||||
| typed command/metadata registry | `EffectiveCommandRegistry`、`BoundCommandRegistry` 与最终 `SchemaRegistry` |
|
||||
| navigation catalog/index | 从同一 `ToolSpec` 派生的 `SchemaIndex` |
|
||||
| schema renderer/envelope | overview、product/group、leaf、`--all` projections |
|
||||
|
||||
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 base Registry 与 reviewed manual command additions 在绑定前合并为唯一的 `EffectiveCommandRegistry`,因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。
|
||||
|
||||
DWS 内部 resolved model 为:
|
||||
|
||||
```text
|
||||
SchemaRegistry
|
||||
-> []ProductSpec
|
||||
-> []ToolSpec
|
||||
-> ToolIdentitySpec
|
||||
-> []ParameterSpec
|
||||
-> RuntimeSchemaConstraints + []RuntimeSchemaPositional
|
||||
-> SafetySpec
|
||||
-> InterfaceSpec
|
||||
-> SelectionSpec
|
||||
-> map[field]FieldProvenance
|
||||
```
|
||||
|
||||
字段合并和 precedence 在进入该模型前完成。`map[string]any`/flat JSON 只允许存在于 renderer 和 snapshot/wire boundary,不能作为内部 resolver、navigation 或 gate 的第二套数据模型。
|
||||
|
||||
DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和选择字段也保持现有键名。架构对齐不等于未版本化地切换到 Lark `inputSchema/outputSchema/_meta` envelope;若未来提供该格式,应作为明确版本的新投影,并保留现有兼容输出。
|
||||
|
||||
## 4. 来源职责
|
||||
|
||||
| 来源 | 负责内容 | 明确不负责 |
|
||||
|---|---|---|
|
||||
| `schema_command_registry.json` | reviewed `CommandRegistry`:稳定 canonical identity、primary CLI path、alias、exposure 和导航 | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
|
||||
| reviewed manual command additions | 将一个精确存在的 runnable Cobra leaf 合并进 `EffectiveCommandRegistry`;必须 reviewed 且带 reason | 运行时 fallback、覆盖冲突 identity、创建命令 |
|
||||
| Go/Cobra | 路径是否真实可执行、Cobra 接受的 flag、CLI 类型/默认值、执行校验、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC |
|
||||
| native Schema identity annotations | implementation-side consistency evidence;存在时必须与 `EffectiveCommandRegistry` 精确一致 | 提供、补全、推断或覆盖 identity |
|
||||
| `schema_hints/metadata/*.json` parameter overlays | 精确覆盖现有 flag 的描述、映射、类型和 required 语义;并承载 safety / `runtime_gate` / interface | 创建命令/flag、绕过 completeness、虚构 RPC |
|
||||
| typed parameter metadata / constraints | `required_when`、one-of、互斥、联动、格式、枚举、位置参数 | 命令 identity |
|
||||
| `schema_parameter_bindings.json` | 稳定 CLI flag 到 RPC property 的映射 | 命令发现、risk 推断 |
|
||||
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
|
||||
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
|
||||
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
|
||||
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
|
||||
|
||||
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
|
||||
|
||||
## 5. 统一解析与 precedence
|
||||
|
||||
### 5.1 Identity
|
||||
|
||||
- Reviewed base `CommandRegistry` 是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源。
|
||||
- Reviewed manual command addition 只能引用精确存在的 runnable Cobra leaf;它在绑定前合并进 `EffectiveCommandRegistry`。若与 base Registry 的 identity/path/alias 冲突,生成失败,不能按 precedence 静默覆盖。
|
||||
- Binder 必须把 effective entry 的 primary path 和每个 alias 精确解析到同一个真实 executable leaf;stale path、phantom path、重复 identity 或 alias collision 全部失败。
|
||||
- Native identity annotation 是可选的一致性证据:存在时必须与 effective entry 精确一致;缺失不触发补写、推断或 fallback。
|
||||
- Public runnable Cobra leaf 未进入 effective registry 时,必须存在 exact、reviewed、带 reason 的 exclusion;不得用 prefix/wildcard 排除。
|
||||
- Identity 不做名称推断,不从 Catalog/generated metadata fallback,也没有多来源 winner。
|
||||
|
||||
删除 native materialization 前已做写入审计:旧
|
||||
`ApplyNativeRuntimeSchemaContracts` 的唯一写操作是对已存在命令调用
|
||||
`AttachRuntimeSchema`,只写 command identity 的 product/tool/source annotation;
|
||||
它不写 flag property/type/required、constraints、positionals、title/description
|
||||
或 interface mapping。这些字段原本已分别由 parameter binding/metadata、
|
||||
constraint、Cobra help 和 interface resolver 提供,因此删除该过渡层没有数据迁移缺口。
|
||||
CI 同时禁止重新加入 generated native contracts 或 materialization 入口。
|
||||
|
||||
#### CommandRegistry 输入审计
|
||||
|
||||
`schema_command_registry.json` 是 reviewed source,不是生成快照。它必须保留
|
||||
`$schema: ./schema_command_registry.schema.json`。该 JSON Schema 对 root、product
|
||||
和 CommandSpec 全部使用 `additionalProperties: false`,并约束:
|
||||
|
||||
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
|
||||
- `aliases` 唯一且不能复用 primary path;
|
||||
- `visibility` 只允许 `public | compat | internal`,省略时明确归一化为
|
||||
`public`;
|
||||
- primary path、alias、canonical 和 product 之间无法由 JSON Schema 表达的
|
||||
交叉约束,继续由 Go strict loader 和 Cobra binder fail-closed 校验。
|
||||
|
||||
Registry semantic hash 覆盖 canonical、primary CLI path、alias 集合、
|
||||
`source_product_id` 和 normalized visibility。格式、顺序以及省略的等价默认值
|
||||
不改变 hash;上述任一稳定契约字段变化都必须改变 hash。测试逐字段验证这一点,
|
||||
不使用当前命令数量作为常量。
|
||||
|
||||
普通 `go generate ./internal/cli` 只把 Registry 作为 validation-only 输入并生成
|
||||
Agent metadata/Catalog 等单向下游资产,不生成或覆盖 Registry。drift policy 在生成
|
||||
前后对 reviewed Registry 做 byte-for-byte guard;独立的
|
||||
`check-schema-command-registry.sh` 在 interface/provenance/Catalog policy 之前检查
|
||||
JSON 输入契约、禁用旧 native materialization 符号,并从 Registry 动态计算审计
|
||||
数量,不能硬编码某次快照的 tool count。
|
||||
|
||||
### 5.2 Parameter
|
||||
|
||||
每个字段按明确的来源 precedence 选择一次,并把 winner、候选值和来源写入 provenance。precedence **与值无关**:不能因为 `required=true` 看起来更严格就让它越级获胜。更高优先级的 reviewed manual override 可以把 `required`、映射、interface type 或描述调高,也可以调低。
|
||||
|
||||
实现中的参数字段顺序固定为:
|
||||
|
||||
```text
|
||||
reviewed manual > versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract > ToolSchemaHint > MCP metadata
|
||||
> inference/default
|
||||
```
|
||||
|
||||
命令 `title` / `description` 使用独立但同样确定的文本顺序:
|
||||
|
||||
```text
|
||||
reviewed ToolSchemaHint > command-specific Cobra Help > MCP metadata > inference
|
||||
```
|
||||
|
||||
因此多个 CLI leaf 复用同一个 RPC 时,通用 RPC 文案只能作为未选中的
|
||||
provenance candidate 保留;参数级 RPC 文案可进入 `interface_description`,
|
||||
但不得覆盖 leaf 自己的标题和执行语义。
|
||||
|
||||
Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/provenance 保留;它不应在 renderer 中再次静默改写已经解析的 Agent projection。
|
||||
|
||||
### 5.3 Safety、selection 与 interface
|
||||
|
||||
`effect`、`risk`、`confirmation`、`idempotency`、selection 和 interface disposition 同样按 source precedence 解析,而不是按值的“严格程度”合并。更高优先级的 reviewed explicit/manual source 可以升高或降低最终值;同 precedence 的不同值必须报冲突。
|
||||
|
||||
最终 interface disposition 还必须满足 conflict matrix:
|
||||
|
||||
- `mode` 与 `availability` 正交:`mode` 只允许 `mcp | local | composite`,`availability` 只允许 `available | unavailable`;`unavailable` 不是第四种 mode。
|
||||
- `mcp + available`:只表示命令可由一个 pinned、参数可映射且语义等价的 `interface_ref` 完整表达;本地 wrapper 只是固定默认值或投影返回值时,也必须先证明参数和执行语义没有漂移。
|
||||
- `local + available`:仅用于纯本地进程、静态数据或策略操作,不得携带 direct `interface_ref`;“远端 RPC 尚未进入 pinned metadata”不能归类为 local。
|
||||
- `composite + available`:用于多 RPC、条件路由、本地投影,或 reviewed unpinned remote adapter;不得用单个 `interface_ref` 冒充完整实现,且必须提供 reviewed reason。未来需要表达多个 RPC 时使用单独的复合接口模型。
|
||||
- 任意合法 mode + `unavailable`:不得携带 `interface_ref`,必须提供明确 reason,并且 Agent 不得把它当作可用接口。
|
||||
|
||||
## 6. Schema、Help 与业务数据边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|---|---|
|
||||
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
|
||||
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
|
||||
- 执行参数以 Cobra 实际接受的 flags 为准;不要发送 Help 中不存在的 flag。
|
||||
- 安全语义冲突时不要采用更宽松值。先按更保守的解释确认;如果无法确定安全执行方式,停止并报告漂移。
|
||||
- Schema/Help 只完成命令发现和契约读取。需要业务结果时,必须继续执行真实 read/search/list 命令。
|
||||
|
||||
上述运行时漂移策略不改变构建期的 value-neutral precedence;前者是在契约已经互相矛盾时保护用户,后者是在确定性生成同一契约。
|
||||
|
||||
## 7. 查询投影
|
||||
|
||||
```bash
|
||||
dws schema # 产品紧凑概览
|
||||
dws schema calendar # 产品摘要
|
||||
dws schema "calendar event" # 分组摘要
|
||||
dws schema "calendar event create" # 完整 leaf
|
||||
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
|
||||
dws schema --all # 所有工具的完整 leaf 导出
|
||||
```
|
||||
|
||||
`schema list` 是根概览的兼容入口。
|
||||
|
||||
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
|
||||
|
||||
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
|
||||
|
||||
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
|
||||
|
||||
## 8. 生成与发布
|
||||
|
||||
当 Cobra、flag、identity、binding、manual hint、Agent hint 或 Skill 发生变化时:
|
||||
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 时,精确编辑 reviewed `CommandRegistry`(当前持久化文件为 `schema_command_registry.json`)。参数、Skill 或 metadata 单独变化时不要机械改写 Registry,也不要从旧 Catalog 反向生成它。
|
||||
2. 仅对明确例外使用 reviewed manual command addition;它必须精确引用现有 runnable leaf、带 reason,并在生成时归一化进 `EffectiveCommandRegistry`。Native identity annotation 若存在,应作为与 Registry 一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成 Agent metadata:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
```
|
||||
|
||||
4. 从统一 typed registry 生成最终 Catalog:
|
||||
|
||||
```bash
|
||||
make generate-schema-catalog
|
||||
```
|
||||
|
||||
也可以运行 `go generate ./internal/cli` 生成正常发布资产。生成文件包括:
|
||||
|
||||
- `internal/cli/schema_agent_metadata/index.json`
|
||||
- `internal/cli/schema_agent_metadata/<product>.json`
|
||||
- `internal/cli/schema_agent_metadata_audit.json`
|
||||
- `internal/cli/schema_catalog.json`
|
||||
|
||||
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
|
||||
|
||||
## 9. Completeness 与 final-delivery invariant
|
||||
|
||||
门禁必须验证最终交付对象,而不是某个中间层或数量:
|
||||
|
||||
- 每个 public runnable Cobra leaf 要么能通过最终 embedded `SchemaIndex` 查询,要么有 exact、reviewed、带 reason 的 exclusion。
|
||||
- 每个最终 canonical path、primary CLI path 和 alias 都必须解析到同一个可执行 leaf;不得有 phantom path 或 collision。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex`、Agent metadata 和 Catalog canonical sets 必须精确一致,不能只比较 count。
|
||||
- Leaf payload、`--all` 中对应 tool 和 Catalog full tool 必须是同一个 resolved `ToolSpec` 的内容级等价投影,并通过 production loader round-trip。
|
||||
- overview/product/group summary 与 Catalog summary 必须等于同一个 `ToolSpec.ToSummaryPayload()`;alias 查询只允许 `cli_path` 和 `is_alias` 这两个视图字段变化。
|
||||
- 每个最终字段及 parameter field 的 provenance winner value 必须与 delivered value 精确一致;不能只验证 provenance source、count 或字段是否存在。
|
||||
- 每个 MCP `interface_ref` 必须在 pinned interface registry 精确存在;local/composite/unavailable 必须满足同一 conflict matrix。
|
||||
- `--all` 的 tool set 必须与最终 index 一对一,且每个工具包含完整参数契约。
|
||||
- 连续两次生成必须字节稳定,提交的生成物不得漂移。
|
||||
|
||||
推荐本地验证:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
make generate-schema-catalog
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-schema-catalog.sh
|
||||
go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
```
|
||||
|
||||
## 10. 明确禁止
|
||||
|
||||
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
|
||||
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
|
||||
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
|
||||
- 用 prefix/wildcard exclusion 隐藏未来命令。
|
||||
- 让 manual hint、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
|
||||
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
|
||||
@@ -0,0 +1,174 @@
|
||||
# Shortcut 真实测试:后端 / MCP 问题整理
|
||||
|
||||
这份报告只汇总 `failure_category = backend-or-mcp-error` 的 case,已尽量排除权限、缺真实资源、当前账号无数据等噪音。
|
||||
|
||||
## 总览
|
||||
|
||||
- Backend/MCP case 总数:33
|
||||
- 聚合问题数:8
|
||||
- 复现口径:真实 dws CLI;无 mock;无 dry-run;命令输入和 trace_id 均来自真实测试结果。
|
||||
|
||||
## 建议优先看
|
||||
|
||||
1. [P1] Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失(15 case)
|
||||
2. [P1] Chat card 发送 receiverUid 疑似未从 receiver 透传(1 case)
|
||||
3. [P1] Chat 入群审批 applicantUid/inviterUid 疑似未透传(1 case)
|
||||
4. [P1] AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error(5 case)
|
||||
5. [P1] AI 表格 Workflow 查询在真实 Base 下返回系统级错误(2 case)
|
||||
6. [P1] AI 表格 roleId 参数疑似未被 MCP 正确读取(3 case)
|
||||
7. [P2] AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR(2 case)
|
||||
8. [P2] AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR(4 case)
|
||||
|
||||
## Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / IM 后端字段映射
|
||||
- 现象:CLI 已传 group/conversation-id/open-conversation-id(部分 case 使用真实 cid),后端仍报 openCid/openConversationId/cid required。
|
||||
- 期望:MCP schema/网关应接受并透传 openConversationId/openCid/cid 中的兼容字段;如果资源无效,应返回“无效会话”,而不是 required。
|
||||
- 涉及 case:15
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `chat +chat-members-get` | `tools/call` | `2127d89817840997754345760e07bd` | [UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-members-get --id DWSREALREADNOSUCHID0000000000000 --users '冬翔' --yes --format json` |
|
||||
| `read` | `chat +chat-messages` | `tools/call` | `2104a64c17840997767792656e085e` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
|
||||
| | 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` |
|
||||
| `read` | `chat +messages-list` | `tools/call` | `2127d89817840997797873841e0757` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-list --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json` |
|
||||
| `write` | `chat +chat-mute-member` | `tools/call` | `2104a64c17840999166036583e08a3` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs |
|
||||
| | 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` |
|
||||
| `write` | `chat +chat-transfer-owner` | `tools/call` | `0b5deb3217840999222318863e087a` | [UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-transfer-owner --group cidDWSREALTESTNOSUCHCONV --new-owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json` |
|
||||
| `write` | `chat +conversation-clear-messages` | `tools/call` | `2127d89817840999254816721e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-messages --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-clear-red-point` | `tools/call` | `2104a64c17840999265511295e085f` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-red-point --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-hide` | `tools/call` | `2127d89817840999276117140e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-hide --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-mark-unread` | `tools/call` | `0bb7c36217840999298744910e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mark-unread --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-mute` | `tools/call` | `2104a64c17840999309241865e085f` | [UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-mute-at-all` | `tools/call` | `2127d89817840999320028068e07dd` | [UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-at-all --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-mute-red-envelope` | `tools/call` | `0bb7c36217840999330228283e07fe` | [UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-red-envelope --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-set-top` | `tools/call` | `2127d89817840999341302961e07fe` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-set-top --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +messages-set-pin` | `tools/call` | `0bb7c36217840999521117991e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-set-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `chat +messages-unset-pin` | `tools/call` | `2104a64c17840999544428103e08ee` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-unset-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
|
||||
## Chat card 发送 receiverUid 疑似未从 receiver 透传
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / card 发送参数映射
|
||||
- 现象:CLI 传入 receiver=103262,后端仍报 receiverUid 和 openConversationId 不能同时为空。
|
||||
- 期望:receiver 应映射为 receiverUid,或 schema 明确要求 receiverUid;真实入参不应在 MCP 层丢失。
|
||||
- 涉及 case:1
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `chat +messages-send-card` | `tools/call` | `2104a64c17840999509255753e081a` | [UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-send-card --receiver 103262 --yes --format json` |
|
||||
|
||||
## Chat 入群审批 applicantUid/inviterUid 疑似未透传
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / 入群审批参数映射
|
||||
- 现象:CLI 传入 applicant=103262、inviter=519019,后端仍报 applicantUid required。
|
||||
- 期望:applicant/inviter 应映射为 applicantUid/inviterUid;如果 recordId/group 无效,应返回对应资源错误而不是 applicantUid 缺失。
|
||||
- 涉及 case:1
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `chat +chat-audit-join` | `tools/call` | `0bb7c36217840999154832733e0758` | [UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs |
|
||||
| | 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` |
|
||||
|
||||
## AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 MCP wrapper
|
||||
- 现象:多条 AI 表格命令返回 MCP_TOOL_ERROR,内部 JSON 同时出现 success=true、status=error、error 非空。
|
||||
- 期望:只要 error 非空或 status=error,success 应为 false,外层也应按业务错误返回稳定错误码/trace。
|
||||
- 涉及 case:5
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +export-data` | `-` | `2104a64c17840997514714448e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"taskId cannot be combined with scope, format, tableId or viewId","retryable":false,"type":"INPUT_ERROR"},"m… |
|
||||
| | 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` |
|
||||
| `write` | `aitable +chart-update` | `-` | `2106d98117840998553244877e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"config is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +chart-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --layout '{}' --yes --format json` |
|
||||
| `write` | `aitable +record-update` | `-` | `0bab027317840998747383236e090b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_RECORDS","message":"records must contain at least one writable record","retryable":false,"type":"INPUT_ERROR"},"meta":{},"stat… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[{"recordId":"recDWSREALTEST","cells":{}}]' --yes --format json` |
|
||||
| `write` | `aitable +record-upsert` | `-` | `2106d98117840998759832182e087b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMETER","message":"records is required and must not be empty","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"e… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-upsert --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[]' --yes --format json` |
|
||||
| `write` | `aitable +view-set-fill-color-rule` | `-` | `2106d98117840998924181709e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"conditionalFormats is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success… |
|
||||
| | 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` |
|
||||
|
||||
## AI 表格 Workflow 查询在真实 Base 下返回系统级错误
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 Workflow MCP / 后端
|
||||
- 现象:使用真实可访问 Base 查询 workflow list/get,返回 LIST_WORKFLOWS_ERROR/GET_WORKFLOW_ERROR。
|
||||
- 期望:无 workflow 时应返回空列表或 WORKFLOW_NOT_FOUND;有后端异常时需提供稳定错误码和可排查 trace。
|
||||
- 涉及 case:2
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +workflow-get` | `-` | `2104a64c17840997556363676e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"GET_WORKFLOW_ERROR","message":"调用远程服务业务异常","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +workflow-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --workflow-id DWSREALREADNOSUCHID0000000000000 --yes --format json` |
|
||||
| `read` | `aitable +workflow-list` | `-` | `2127d89817840997572427879e075d` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"LIST_WORKFLOWS_ERROR","message":"biz error","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +workflow-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --limit 10 --offset 1 --yes --format json` |
|
||||
|
||||
## AI 表格 roleId 参数疑似未被 MCP 正确读取
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 MCP role 接口
|
||||
- 现象:CLI 已传 --role-id,但 MCP 返回 roleId is required。
|
||||
- 期望:role-id/roleId 字段应被正确映射;如果 role 不存在,返回 ROLE_NOT_FOUND,而不是 required。
|
||||
- 涉及 case:3
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +role-get` | `-` | `2104a64c17840997542904838e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --role-id x --yes --format json` |
|
||||
| `write` | `aitable +role-delete` | `-` | `2106d98117840998782482701e089c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-delete --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +role-update` | `-` | `2132f5ca17840998794483634e08d8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | 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` |
|
||||
|
||||
## AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR
|
||||
|
||||
- 优先级:P2
|
||||
- 建议 owner:AI 表格 primary doc MCP / 后端
|
||||
- 现象:record-query 已能查到真实 recordId,但 primary-doc 查询返回 no record、type=SYSTEM_ERROR。
|
||||
- 期望:若该记录无主文档,应返回空/未创建;若 recordId 语义不匹配,应返回明确参数错误,不应是系统错误。
|
||||
- 涉及 case:2
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +base-get-primary-doc-id` | `-` | `0b5deb3217840997466255627e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +base-get-primary-doc-id --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
|
||||
| `read` | `aitable +record-primary-doc-get` | `-` | `2127d89817840997528393730e079c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-primary-doc-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
|
||||
|
||||
## AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR
|
||||
|
||||
- 优先级:P2
|
||||
- 建议 owner:AI 表格 MCP wrapper / 后端错误码
|
||||
- 现象:安全负向 ID 下,部分写接口返回 getDentryDTO returns null、type=SYSTEM_ERROR、retryable=true。
|
||||
- 期望:资源不存在应返回 INPUT_ERROR/NOT_FOUND 且 retryable=false,避免误导调用方重试。
|
||||
- 涉及 case:4
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `aitable +field-delete` | `-` | `0bab027317840998611338768e08c8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +field-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +field-update` | `-` | `213ee25c17840998623207342e08e2` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | 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` |
|
||||
| `write` | `aitable +record-delete` | `-` | `2132f5ca17840998724753149e0853` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --record-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +table-update` | `-` | `213ee25c17840998877246229e087f` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | 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` |
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,279 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Shortcut 真实测试失败逐项 review</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#151d2b;--line:#263246;--text:#dce7f7;--muted:#91a0b5;--blue:#8fd3ff;--green:#66d38a;--yellow:#e2b23c;--red:#f27272;--purple:#d3a7ff}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||
header{padding:28px 32px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#172033,#0f1420)}
|
||||
h1{margin:0 0 8px;font-size:26px}
|
||||
h2{margin:28px 0 10px;font-size:18px}
|
||||
.sub,.note,.count{color:var(--muted)}
|
||||
.wrap{padding:18px 32px 40px;max-width:1800px;margin:0 auto}
|
||||
.note{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:10px 0 18px}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:18px 0}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px}
|
||||
.stat .n{font-size:24px;color:var(--blue);font-weight:700}
|
||||
.stat .l{color:var(--muted);font-size:12px}
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin:16px 0 22px}
|
||||
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
th,td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
|
||||
th{background:#1b2536;color:var(--muted);font-size:12px;font-weight:600;position:sticky;top:0;z-index:1}
|
||||
tr:last-child td{border-bottom:none}
|
||||
code{font-family:"SF Mono",Menlo,Consolas,monospace;color:#c7cfdb;font-size:12px}
|
||||
.review{table-layout:fixed}
|
||||
.review th:nth-child(1),.review td:nth-child(1){width:44px}
|
||||
.review th:nth-child(2),.review td:nth-child(2){width:165px}
|
||||
.review th:nth-child(3),.review td:nth-child(3){width:70px}
|
||||
.review th:nth-child(4),.review td:nth-child(4){width:120px}
|
||||
.review th:nth-child(5),.review td:nth-child(5){width:130px}
|
||||
.review th:nth-child(8),.review td:nth-child(8){width:130px}
|
||||
.review th:nth-child(9),.review td:nth-child(9){width:180px}
|
||||
.review td{word-break:break-word}
|
||||
.num{color:var(--muted);text-align:right}
|
||||
.risk{color:var(--green)}
|
||||
.cat{color:var(--yellow)}
|
||||
.owner{color:var(--purple)}
|
||||
.evidence{color:#c7cfdb;font-size:12px}
|
||||
a{color:var(--blue)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Shortcut 真实测试失败逐项 review</h1>
|
||||
<div class="sub">由 <code>scripts/gen_shortcut_error_review.py</code> 从真实测试结果生成;目标是把每个失败项落到“应该改哪里”。</div>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
<div class="note">
|
||||
Read:204 条,成功 162,失败 41,超时 0。
|
||||
Write:162 条,成功 48,失败 114,超时 0。
|
||||
判定口径:如果 fake MCP 已看到字段但真实后端仍报 required,按后端/MCP schema 映射处理;如果真实后端报资源无效/不存在,按 fixture 处理;权限类不在 CLI 中绕过。
|
||||
</div>
|
||||
|
||||
<div class="stats"><div class="stat"><div class="n">41</div><div class="l">Read 失败</div></div>
|
||||
<div class="stat"><div class="n">114</div><div class="l">Write 失败</div></div>
|
||||
<div class="stat"><div class="n">156</div><div class="l">逐项 review</div></div>
|
||||
<div class="stat"><div class="n">72</div><div class="l">测试数据/fixture</div></div>
|
||||
<div class="stat"><div class="n">22</div><div class="l">权限/应用配置</div></div>
|
||||
<div class="stat"><div class="n">33</div><div class="l">后端/MCP schema</div></div></div>
|
||||
<div class="summary-grid">
|
||||
<section><h2>按错误类型</h2><table><thead><tr><th>类型</th><th>数量</th></tr></thead><tbody><tr><td>输入/业务校验</td><td>36</td></tr>
|
||||
<tr><td>后端/MCP</td><td>33</td></tr>
|
||||
<tr><td>缺 AI 表格 fixture</td><td>31</td></tr>
|
||||
<tr><td>缺真实资源</td><td>30</td></tr>
|
||||
<tr><td>鉴权/权限</td><td>22</td></tr>
|
||||
<tr><td>缺妙记 fixture</td><td>3</td></tr>
|
||||
<tr><td>敏感/高风险暂缓</td><td>1</td></tr></tbody></table></section>
|
||||
<section><h2>按要改哪里</h2><table><thead><tr><th>要改哪里</th><th>数量</th></tr></thead><tbody><tr><td>测试数据</td><td>72</td></tr>
|
||||
<tr><td>后端/MCP schema</td><td>33</td></tr>
|
||||
<tr><td>权限/应用配置</td><td>22</td></tr>
|
||||
<tr><td>测试输入/业务校验</td><td>13</td></tr>
|
||||
<tr><td>后端业务/测试 fixture</td><td>11</td></tr>
|
||||
<tr><td>测试输入</td><td>2</td></tr>
|
||||
<tr><td>人工安全确认</td><td>1</td></tr>
|
||||
<tr><td>测试输入/shortcut 枚举</td><td>1</td></tr>
|
||||
<tr><td>测试输入/业务规则</td><td>1</td></tr></tbody></table></section>
|
||||
</div>
|
||||
|
||||
|
||||
<h2>缺真实资源复盘 <span class="count">· 可自造/可查资源处理结果</span></h2>
|
||||
<table>
|
||||
<thead><tr><th>命令/范围</th><th>处理状态</th><th>本次实际排查/造数结果</th><th>后续建议</th></tr></thead>
|
||||
<tbody><tr><td><code>chat +group-members</code></td><td><span class="cat">已补齐</span></td><td>查到真实群名 `浅曦-kida,Dennis,秋画`,runner 已改为用群名而不是 openConversationId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-mget</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊消息 `msgEuOor1PmFBNlx9M06N9z1Q==`;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-read-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊会话 `cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=` 与同一 openMessageId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-query-send-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实发送返回的 openTaskId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>ding +receiver-status</code></td><td><span class="cat">已补齐</span></td><td>先只读 `ding +list` 找到已有 openDingId,再查询 receiver status;没有新发 DING,真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>sheet +list-sheets</code></td><td><span class="cat">已自造</span></td><td>创建临时在线表格 `DWS shortcut 真实测试表格 20260715`,nodeId=`mweZ92PV6O36dZbnsMZx70ylJxEKBD6p`;真实回归成功。</td><td>后续可保留为稳定 fixture,或测试结束后人工清理。</td></tr>
|
||||
<tr><td><code>todo +todo-done</code></td><td><span class="cat">已自造并修复 CLI</span></td><td>runner 会先创建当前账号自己的临时待办,再执行 `todo +todo-done`;同时修复了 todo 列表 pageSize=50 返回空、响应多层 result unwrap 不稳的问题;真实回归成功。</td><td>无需后续动作;代码已有单测覆盖 nested result。</td></tr>
|
||||
<tr><td><code>contact +by-mobile</code></td><td><span class="cat">已按用户授权补齐</span></td><td>使用用户指定手机号 `13161187007` 作为真实 fixture;runner 只在该命令上替换 mobile,不扩散到其它服务。</td><td>真实回归成功后该项将从失败列表移除;若后续要脱敏公开报告,可再加展示层脱敏。</td></tr>
|
||||
<tr><td><code>attendance +get-class / +get-group / +get-group-filtered</code></td><td><span class="cat">不建议自造</span></td><td>`attendance +search-class` 与 `+search-group --type FIXED` 均返回空;创建班次/考勤组会改组织考勤配置,属于高影响业务数据。</td><td>需要考勤后端/业务同学提供可读测试班次与考勤组 ID。</td></tr>
|
||||
<tr><td><code>chat +chat-get-by-id</code></td><td><span class="cat">暂未找到</span></td><td>该 shortcut 只接受数字 groupId;真实群列表只返回 openConversationId,没有数字群号字段。</td><td>需要 IM 后端提供可用数字 groupId,或评估是否新增 openConversationId 形态的 shortcut。</td></tr>
|
||||
<tr><td><code>chat +messages-resource-url</code></td><td><span class="cat">暂未自造</span></td><td>需要真实含 mediaId 的图片/文件/视频消息;当前文本消息无法产生 resource-id。</td><td>可在测试群发一条图片/文件消息并提取 mediaId 后补 fixture;注意会产生群消息。</td></tr>
|
||||
<tr><td><code>chat +thread-replies</code></td><td><span class="cat">暂未自造</span></td><td>需要真实话题消息 topicId;普通群消息不能替代。</td><td>需要话题群 fixture,或由 IM 同学提供当前账号可访问 topicId。</td></tr>
|
||||
<tr><td><code>aitable +dashboard-share-get</code></td><td><span class="cat">真实资源仍失败</span></td><td>已有真实 base/dashboard,但 share-get 返回 404 `Failed to get dashboard share config`;更像分享配置未开启或后端接口行为问题。</td><td>需要 AI 表格/后端确认如何创建/开启 dashboard share fixture,或修正 404 语义。</td></tr>
|
||||
<tr><td><code>write 类 delete/recall/approve/wiki move/copy 等</code></td><td><span class="cat">不自动自造</span></td><td>这些命令即便能造资源,也会涉及删除、撤回、审批通过、知识库移动/复制等高影响动作。</td><td>需要逐项授权和专门测试空间/机器人/审批单据,不建议混在批量回归里自动跑。</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Read 失败逐项 <span class="count">· 42 条</span></h2>
|
||||
<table class="review">
|
||||
<thead><tr>
|
||||
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="num">1</td><td><code>aitable +base-get-primary-doc-id</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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…</td></tr>
|
||||
<tr><td class="num">2</td><td><code>aitable +chart-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +chart-share-get`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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-dlxFo…</td></tr>
|
||||
<tr><td class="num">3</td><td><code>aitable +dashboard-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +dashboard-share-get`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 KY9…</td></tr>
|
||||
<tr><td class="num">4</td><td><code>aitable +export-data</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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,…</td></tr>
|
||||
<tr><td class="num">5</td><td><code>aitable +record-primary-doc-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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…</td></tr>
|
||||
<tr><td class="num">6</td><td><code>aitable +role-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">7</td><td><code>aitable +workflow-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 'gpG2Nd…</td></tr>
|
||||
<tr><td class="num">8</td><td><code>aitable +workflow-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 'gpG…</td></tr>
|
||||
<tr><td class="num">9</td><td><code>attendance +get-class</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2127d89817840997588238831e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">10</td><td><code>attendance +get-global-setting</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +get-global-setting`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997604718062e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">11</td><td><code>attendance +get-group</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997620512305e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">12</td><td><code>attendance +get-group-filtered</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997638062468e08c7</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">13</td><td><code>attendance +get-leave-balance</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `attendance +get-leave-balance` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840997652901307e081a</code></td><td class="evidence">[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">14</td><td><code>attendance +list-report-columns</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +list-report-columns`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840997666188472e0756</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">15</td><td><code>attendance +query-report-leave</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +query-report-leave`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997679973065e085f</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">16</td><td><code>calendar +find-room</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997694975303e0758</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">17</td><td><code>calendar +room-find</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997709913005e0819</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">18</td><td><code>chat +category-list-conversations</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +category-list-conversations` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997724375834e0758</code></td><td class="evidence">[UNCLASSIFIED] listConversationsByCategoryV2 error hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">19</td><td><code>chat +chat-get-by-id</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-get-by-id`。</td><td><code>tools/call</code></td><td><code>2127d89817840997739165535e07bd</code></td><td class="evidence">[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">20</td><td><code>chat +chat-members-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997754345760e07bd</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">21</td><td><code>chat +chat-messages</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840997767792656e085e</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">22</td><td><code>chat +messages-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997797873841e0757</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">23</td><td><code>chat +messages-resource-url</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-resource-url`。</td><td><code>tools/call</code></td><td><code>2127d89817840997855423145e07dd</code></td><td class="evidence">[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">24</td><td><code>chat +search-msg</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997866824643e0853</code></td><td class="evidence">[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">25</td><td><code>chat +thread-replies</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +thread-replies`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997883504915e0853</code></td><td class="evidence">[UNCLASSIFIED] failed to decrypt openConvThreadId hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">26</td><td><code>contact +get-roster</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +get-roster`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2106d98117840997920166915e087b</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">27</td><td><code>contact +list-roster-fields</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +list-roster-fields`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>213ee25c17840997934295673e08e2</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">28</td><td><code>devapp +credentials-get</code></td><td><span class="risk">read</span></td><td><span class="cat">敏感/高风险暂缓</span></td><td><span class="owner">人工安全确认</span></td><td>该项涉及敏感读取或无安全负向目标,不适合自动用真实资源跑;需要在安全环境逐项人工确认。</td><td>人工确认后单独重跑 `devapp +credentials-get`,并避免在报告中泄露密钥/凭证。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。</td></tr>
|
||||
<tr><td class="num">29</td><td><code>drive +download</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +download` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bab027317840997956923965e08c9</code></td><td class="evidence">[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">30</td><td><code>drive +list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +list` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2106d98117840997968627612e087b</code></td><td class="evidence">[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">31</td><td><code>minutes +action-items</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">32</td><td><code>minutes +latest-minutes</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">33</td><td><code>minutes +minutes-search</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没搜到妙记</td></tr>
|
||||
<tr><td class="num">34</td><td><code>minutes +transcript</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">35</td><td><code>oa +done-approvals</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有已处理的审批记录</td></tr>
|
||||
<tr><td class="num">36</td><td><code>oa +pending</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">当前没有待我审批的任务</td></tr>
|
||||
<tr><td class="num">37</td><td><code>report +report-latest</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无日志</td></tr>
|
||||
<tr><td class="num">38</td><td><code>todo +due-today</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">今天没有到期的待办</td></tr>
|
||||
<tr><td class="num">39</td><td><code>todo +related-tasks</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有与你相关的待办(creator/executor/participant 三种角色下均为空)</td></tr>
|
||||
<tr><td class="num">40</td><td><code>wiki +node-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2132f5ca17840998189126304e08d9</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">41</td><td><code>wiki +resolve-space</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有找到名称包含 DWS shortcut 真实测试 的知识空间</td></tr>
|
||||
<tr><td class="num">42</td><td><code>wiki +space-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `wiki +space-list` 并比较 stdout/stderr。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">参数 --type 取值 "ALL" 不合法,允许值:orgWikiSpace, myWikiSpace</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Write 失败逐项 <span class="count">· 114 条</span></h2>
|
||||
<table class="review">
|
||||
<thead><tr>
|
||||
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="num">1</td><td><code>aitable +advperm-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-disable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 adv…</td></tr>
|
||||
<tr><td class="num">2</td><td><code>aitable +advperm-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-enable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 adv…</td></tr>
|
||||
<tr><td class="num">3</td><td><code>aitable +attachment-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":t…</td></tr>
|
||||
<tr><td class="num">4</td><td><code>aitable +base-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":null,"message":"Invalid source baseId: DWSREALTESTNOSUCHID0000000000000","retryable":null,"type":"USER_ERROR"},"meta":{},"status":"error","success":true,"summary":"Invalid sou…</td></tr>
|
||||
<tr><td class="num">5</td><td><code>aitable +base-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +base-delete`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 DWSREALTESTNOSUCHID000…</td></tr>
|
||||
<tr><td class="num">6</td><td><code>aitable +base-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":t…</td></tr>
|
||||
<tr><td class="num">7</td><td><code>aitable +chart-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">8</td><td><code>aitable +chart-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">9</td><td><code>aitable +chart-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 con…</td></tr>
|
||||
<tr><td class="num">10</td><td><code>aitable +dashboard-arrange</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 d…</td></tr>
|
||||
<tr><td class="num">11</td><td><code>aitable +dashboard-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">12</td><td><code>aitable +dashboard-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">13</td><td><code>aitable +dashboard-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">14</td><td><code>aitable +field-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 befor…</td></tr>
|
||||
<tr><td class="num">15</td><td><code>aitable +field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 u…</td></tr>
|
||||
<tr><td class="num">16</td><td><code>aitable +form-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">17</td><td><code>aitable +form-field-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">18</td><td><code>aitable +form-field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">19</td><td><code>aitable +form-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">20</td><td><code>aitable +form-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">21</td><td><code>aitable +import-data</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +import-data`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":"impo…</td></tr>
|
||||
<tr><td class="num">22</td><td><code>aitable +import-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":"0bab027317840998…</td></tr>
|
||||
<tr><td class="num">23</td><td><code>aitable +record-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":"…</td></tr>
|
||||
<tr><td class="num">24</td><td><code>aitable +record-primary-doc-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 c…</td></tr>
|
||||
<tr><td class="num">25</td><td><code>aitable +record-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":"Fa…</td></tr>
|
||||
<tr><td class="num">26</td><td><code>aitable +record-upsert</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">27</td><td><code>aitable +role-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">28</td><td><code>aitable +role-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 role…</td></tr>
|
||||
<tr><td class="num">29</td><td><code>aitable +role-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 roleI…</td></tr>
|
||||
<tr><td class="num">30</td><td><code>aitable +section-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">31</td><td><code>aitable +section-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">32</td><td><code>aitable +section-move-node</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 no…</td></tr>
|
||||
<tr><td class="num">33</td><td><code>aitable +section-rename</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">34</td><td><code>aitable +section-reorder</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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…</td></tr>
|
||||
<tr><td class="num">35</td><td><code>aitable +table-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":t…</td></tr>
|
||||
<tr><td class="num">36</td><td><code>aitable +table-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 DWSREALTESTNOSU…</td></tr>
|
||||
<tr><td class="num">37</td><td><code>aitable +view-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">38</td><td><code>aitable +view-duplicate</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 duplica…</td></tr>
|
||||
<tr><td class="num">39</td><td><code>aitable +view-lock</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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…</td></tr>
|
||||
<tr><td class="num">40</td><td><code>aitable +view-set-fill-color-rule</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 col…</td></tr>
|
||||
<tr><td class="num">41</td><td><code>aitable +view-set-frozen-cols</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 fro…</td></tr>
|
||||
<tr><td class="num">42</td><td><code>aitable +view-set-row-height</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 cel…</td></tr>
|
||||
<tr><td class="num">43</td><td><code>aitable +view-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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 …</td></tr>
|
||||
<tr><td class="num">44</td><td><code>aitable +workflow-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":{},"sta…</td></tr>
|
||||
<tr><td class="num">45</td><td><code>aitable +workflow-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[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":{},"sta…</td></tr>
|
||||
<tr><td class="num">46</td><td><code>attendance +boss-check</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>213ee25c17840998998942184e087d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">47</td><td><code>attendance +create-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0bab027317840999009926838e090b</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">48</td><td><code>attendance +create-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2106d98117840999021121258e08b8</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">49</td><td><code>attendance +import-schedule</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999034845189e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">50</td><td><code>attendance +save-leave-balance</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +save-leave-balance`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999045751134e079c</code></td><td class="evidence">[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">51</td><td><code>attendance +update-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999058236471e08b5</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">52</td><td><code>attendance +update-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999068826691e087a</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">53</td><td><code>attendance +update-group-members</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999081094644e07dd</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">54</td><td><code>attendance +update-leave-type</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +update-leave-type`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999093924897e07fe</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">55</td><td><code>calendar +respond-event</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `calendar +respond-event`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999105062121e07db</code></td><td class="evidence">[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">56</td><td><code>chat +category-add-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-add-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999117776203e08f9</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">57</td><td><code>chat +category-remove-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-remove-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999130466520e085e</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">58</td><td><code>chat +chat-add-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +chat-add-bot`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999144193460e08c7</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">59</td><td><code>chat +chat-audit-join</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999154832733e0758</code></td><td class="evidence">[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">60</td><td><code>chat +chat-mute-member</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999166036583e08a3</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">61</td><td><code>chat +chat-quit</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-quit` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999176728426e0779</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">62</td><td><code>chat +chat-remove-bot</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-remove-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999189888305e08b5</code></td><td class="evidence">[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">63</td><td><code>chat +chat-role-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999200483204e079c</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">64</td><td><code>chat +chat-role-remove-user</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove-user` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999211317952e0757</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">65</td><td><code>chat +chat-transfer-owner</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999222318863e087a</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">66</td><td><code>chat +chat-update-icon</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-update-icon` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840999232478654e0819</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">67</td><td><code>chat +chat-update-settings</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/shortcut 枚举</span></td><td>把测试输入的 setting-key 从 x 改为后端支持的 key;同时可在 shortcut flag 上补 enum,避免用户传非法 key。</td><td>改 runner 后重跑;如果补 enum,跑 shortcut 单测确认校验文案。</td><td><code>tools/call</code></td><td><code>2127d89817840999244326971e07dd</code></td><td class="evidence">[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">68</td><td><code>chat +conversation-clear-messages</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999254816721e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">69</td><td><code>chat +conversation-clear-red-point</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999265511295e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">70</td><td><code>chat +conversation-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999276117140e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">71</td><td><code>chat +conversation-mark-read</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2127d89817840999287654280e07bd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">72</td><td><code>chat +conversation-mark-unread</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999298744910e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">73</td><td><code>chat +conversation-mute</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999309241865e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">74</td><td><code>chat +conversation-mute-at-all</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999320028068e07dd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">75</td><td><code>chat +conversation-mute-red-envelope</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999330228283e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">76</td><td><code>chat +conversation-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999341302961e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">77</td><td><code>chat +messages-add-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999352941910e0756</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">78</td><td><code>chat +messages-add-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999362862711e08b5</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">79</td><td><code>chat +messages-batch-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-recall-by-bot`。</td><td><code>tools/call</code></td><td><code>2104a64c17840999373456305e0817</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">80</td><td><code>chat +messages-batch-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999385748776e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">81</td><td><code>chat +messages-combine-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999396596163e08ee</code></td><td class="evidence">[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">82</td><td><code>chat +messages-create-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务规则</span></td><td>换成后端支持的文字表情组合,或把该命令保留为业务负向;CLI 不应绕过后端限制。</td><td>用一个真实可保存的表情模板重跑。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999407422891e08cd</code></td><td class="evidence">[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">83</td><td><code>chat +messages-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999417966407e08ee</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">84</td><td><code>chat +messages-forward-topic</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999428541474e07dd</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">85</td><td><code>chat +messages-recall</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999441138103e08c7</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">86</td><td><code>chat +messages-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999454382709e08a3</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">87</td><td><code>chat +messages-remove-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999467733059e08f9</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">88</td><td><code>chat +messages-remove-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999478923406e087f</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">89</td><td><code>chat +messages-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999494005921e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">90</td><td><code>chat +messages-send-card</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999509255753e081a</code></td><td class="evidence">[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">91</td><td><code>chat +messages-set-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999521117991e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">92</td><td><code>chat +messages-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999532438476e0817</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">93</td><td><code>chat +messages-unset-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999544428103e08ee</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">94</td><td><code>chat +messages-unset-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999554154221e08f9</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">95</td><td><code>devapp +event-subscribe</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-subscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999564022020e08c7</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">96</td><td><code>devapp +event-unsubscribe</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-unsubscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999575698500e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">97</td><td><code>devapp +permission-add</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-add`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999587758457e079c</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">98</td><td><code>devapp +permission-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-remove`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999598445247e085e</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">99</td><td><code>devapp +robot-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999609828961e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">100</td><td><code>devapp +robot-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-disable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999620461113e08ee</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">101</td><td><code>devapp +robot-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-enable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999632641525e0758</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">102</td><td><code>devapp +security-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +security-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999645522046e0817</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">103</td><td><code>devapp +version-create</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999656705920e0853</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">104</td><td><code>devapp +version-publish</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999667906017e075d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">105</td><td><code>ding +send-by-message</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `ding +send-by-message` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999678466213e0853</code></td><td class="evidence">[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">106</td><td><code>doc +comment-create-inline</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>2127d89817840999689931893e079c</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">107</td><td><code>doc +template-apply</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `doc +template-apply`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999701186954e0757</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">108</td><td><code>minutes +record-pause</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-pause` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999713124987e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">109</td><td><code>minutes +record-resume</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-resume` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999724452848e0758</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">110</td><td><code>minutes +record-stop</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-stop` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999735397508e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">111</td><td><code>oa +approve-by</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `oa +approve-by`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到待审批单据:待我处理的审批里没有标题/单号包含 "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__" 的单据。</td></tr>
|
||||
<tr><td class="num">112</td><td><code>wiki +node-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999769818495e0779</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">113</td><td><code>wiki +node-move</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999780286509e07fe</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">114</td><td><code>wiki +wiki-new-doc</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `wiki +wiki-new-doc`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到名为 "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__" 的知识库;换个更完整/精确的空间名再试。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,223 @@
|
||||
# DWS Shortcut — 方法论与进展交接文档(换会话续跑用)
|
||||
|
||||
> 目的:换新会话直接照此续跑。记录**方法论、已完成进展、如何继续、关键坑位、验证命令**。
|
||||
> 分支:`feature/shortcut`(**改动全部未提交**,commit 由用户主动决定)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话现状
|
||||
|
||||
> 2026-07-09 **去冗余(重大修订)**:复盘发现 `internal/helpers/` 早有 ~697 个 `dws <svc> <verb>` 产品命令封装了 281 个 tool;1:1 shortcut 层有 235 个 tool 与之重复。按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 **213 条纯重复 shortcut**,1:1 层 511→298、总数 579→366、服务 19→16(aisearch/live/devdoc 整包移除,其 dws 命令仍由 helper 提供)。全绿+真机复验保留命令可用。**教训:建封装层前先审已有封装。**
|
||||
|
||||
|
||||
在 `internal/shortcut/` 下建成一套声明式 shortcut 体系:**366 条命令 = 298 条 1:1 封装 + 68 条真·智能编排**(1:1 层原 511,已删 213 条纯重复——helper 层早已封装同一批 tool),另有 **~60 条封装升级到 lark 输出投影保真度**、P2 高频自动沉淀闭环、深度对齐 lark 矩阵。**全绿**(build/gofmt/vet/shortcut 全量测试 `shortcuts=366 assembled=322 validated=44 failed=0`/app 全量回归 72s/真机抽验)。
|
||||
|
||||
> 2026-07-09 批33(净新增 1 条·+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段,list_calendar_events + 合并忙碌区间 + 工作窗口求补集,默认今天 09:00-18:00/--from/--to/--in-days)。真机验证:今日 4 段空档(09:00-09:15/15min、12:00-13:30/90min、14:00-14:30/30min、16:00-17:15/75min),正是忙碌事件的精确补集。conflicts+free-slots 构成真实排期智能。总数 578→579、smart 67→68。全绿。
|
||||
|
||||
> 2026-07-09 批32b(净新增 1 条·dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两 [start,end) 重叠检测,默认今天/--in-days)。真机验证:抓到今日 9 个日程里 2 处真实冲突(技术标评审 10:00-11:00 × AIX 共创 10:30-12:00;尖角班 14:30-15:30 × 中控项目 15:00-16:00)。证明复杂写死胡同之外,纯 MCP-tool 的本地编排仍有净新增价值。总数 577→578、smart 66→67。全绿。
|
||||
|
||||
> 2026-07-09 批32(review lark 复杂写类 + 架构边界结论,用户指定方向):fresh review lark 复杂写 shortcut(mail +send/+reply/+forward、drive +import、doc +media-insert/download、base +record-upload-attachment),交叉 dws helpers。**关键结论——架构性死胡同,非没使劲**:① `mail +send` dws 已有 1:1 `+send`(send_email)+`+draft-*`,且 **contact 无 email 字段**→无法按名解析收件人(矩阵早 skip),也无 signature/template/lint 工具;② `drive +import`/`doc +media-*`/`base +record-upload-attachment` 核心是**本地文件字节 PUT/下载落盘**——dws 里由 helper 内部 `httpPutFile`/`http PUT/GET`(drive.go/doc.go)实现、已在 1:1 层覆盖(如 `drive upload --convert`),但 **shortcut 框架 `rt.CallMCP/CallMCPData` 只编排 MCP tool、结构上做不了原始文件 I/O**,故无法在 smart 层组合。**建议**:剩余复杂写要么卡此边界、要么已被 1:1 覆盖;真要补文件类能力应在 helper/1:1 层加命令,而非 shortcut 层。本批 review、无代码改动,总数仍 577。注:本轮触及 session 限额(8:20pm 重置)+ 分类器一度不可用,Bash 受限。
|
||||
|
||||
> 2026-07-09 批31(净新增 1 条):`calendar +my-free`(我自己的忙闲,自动解析当前 userId、默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名)。真机正向验证:返回今日/明日真实忙碌时段 {busy:[{start,end}],userId,free}。总数 576→577、smart 65→66。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批30(净新增 1 条):`contact +me`(当前用户 `get_current_user_profile` + 投影 `{name,userId,mobile,dept,org,email}`,agent 的「我是谁」;区别于 1:1 `+get-self` 吐冗长 `result[].orgEmployeeModel` raw)。真机正向验证:董鑫阳/202397/模型算法/钉钉。总数 575→576、smart 64→65。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批29(净新增便利读 3 条 + 真机抓修 1 bug):`oa +done-approvals`(审批历史 get_done_tasks)、`mail +recent-mail`(近期收件 list_mailbox_threads + 解析绑定邮箱/收件箱 folder)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance)。**真机抓到并修复 bug**:`+done-approvals` 原来 --limit 不传时 pageSize=0 → 后端 business error(1:1 list-executed 有默认所以正常);改为默认 pageSize=20,真机复验走空路径「没有已处理的审批记录」。+this-month 真机有效空、+recent-mail 正确报未绑定邮箱。总数 572→575、smart 61→64。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批28(净新增便利读 smart,多 agent 并行 + 手工):再建 3 条只读 smart——`oa +pending`(`list_pending_approvals` 只读列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(`get_user_todos_in_current_org` + `planFinishDateStart/End` 服务端过滤今天到期,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。真机:+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper。3 条为 dws 原生便利读、不对应 lark gap,矩阵 42/48 不变,总数 569→572、smart 58→61。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批27(写类输出扫荡收尾,确认无更多 bug):扫 smart 里丢弃 CallMCPData 结果的 3 处——`broadcast`(per-recipient 循环、最终结构化输出,OK)、`book`(弃 add-participant 结果但最终 `get_calendar_detail` 确认 + 失败回滚,OK)、`reschedule`(弃存在性 check detail 是有意的,随后打 update 结果,OK)。**无更多 silent-success bug**。结论:**输出质量扫荡完成**,只读投影 clean、写类确认结果、honor --format。剩余仅复杂 net-new gap-buildable(mail +send/drive +import 等多步写、难安全真机验)或 commit。真机+一致性改进累计 13 条,总数 569,全绿。
|
||||
|
||||
> 2026-07-09 批26(写类 smart 输出一致性批量修):扫 smart 里用 `fmt.Print*` / 无标准输出的。修 2 条:`chat +broadcast`(`fmt.Printf` 群发摘要忽略 --format → `rt.Output({sentCount,failedCount,sent,failed})`);`wiki +wiki-new-doc`(**原创建文档后丢弃 create_file 结果、静默 return nil**,真 UX bug 拿不到新文档 id/url → 捕获并 `rt.Output({created,space,title,result})`)。写类无法真机验(会真建/发),assemble 测试确认组装正确、低风险。总数仍 569。
|
||||
|
||||
> 2026-07-09 批25(+next-event 输出一致性修复 + sweep 确认多数已 clean):sweep 探 chat +conversation-list/+category-list、aitable +base-list 等——**多数 1:1 只读命令输出已 clean**(属先前 ~60 升级覆盖),保真度工作基本到位。**修复 1 条一致性**:`calendar +next-event` 原用 `fmt.Println` 打固定文本行、**忽略 --format/--jq/--fields**,改为 `rt.Output(map{event:项目投影})`(复用 +today/+week 同款投影),真机复验 `--format json` 出结构化 `{event:{title,start,end,location,eventId}}`、`--jq '.event.title'` 可用。这是 Agent 友好性修复。同时删除死代码 `shortcutNextEventSummary` + 无用 fmt import。总数仍 569。
|
||||
|
||||
> 2026-07-09 批24(1:1 层保真度升级续):`contact +list-followings` 原 `rt.CallMCP` 吐 `{arguments,result:{models:[…]}}` 信封噪音,改为 `CallMCPData`+`listFollowingsProject`,真机复验干净 `{count:13, followings:[{openDingTalkId}]}`。总数仍 569。**判断**:真机验证 + 保真度升级已到深度边际收益区(本批仅拍平 ID 列表);1:1 层多数只读命令要么需特定参数、要么后端权限受限、要么输出已可接受。**建议优先 commit 留存 24 批成果**(10 个真机改进 + 58 smart + 保真度升级 + P2 + 全套文档),再按需推进剩余 1:1 微升级。
|
||||
|
||||
> 2026-07-09 批23(验证驱动的 1:1 层保真度升级起步):真机探 1:1 只读命令,`drive +recent` 原 `rt.CallMCP` 吐冗长 raw(logId/nextCursor 噪音 + 每项巨型 docUrl + hasMore),改为 `CallMCPData`+`recentListProject`:投影 `{count, hasMore, items:[{name,nodeType,contentType,accessTime,docUrl,nodeId}], nextCursor}`,去 logId 噪音、保留分页与链接,真机复验干净。`drive +list-spaces` 真机空(有 errorCode 包裹噪音但 result.items 为空,暂不动)。总数仍 569。**注**:1:1 层仍有数十个 list 命令可类似升级,但属边际收益、量大,建议按需/被动推进,优先 commit 留存已有成果。
|
||||
|
||||
> 2026-07-09 批22(报告综合更新,反映真机验证战役):给 `shortcut-report.md` 新增 §2.4「真机验证战役」:记录 9 批真机验证(正向验证 20+ 条、抓修 8 个真实 bug 的表格、后端受限项、resolveUser 非 bug 澄清);`shortcut-report.html` §③ 测试表补 2 行 + 一段说明。诚实反映「assemble 合成测试盲区 → 真机验证补齐」的价值。纯文档,无代码改动,总数仍 569。
|
||||
|
||||
> 2026-07-09 批21(find-record 验证 + +suggest-time 保真度升级):`aitable +find-record` 真机正常(返回真实记录;cells 按字段 ID 键值、内含附件对象,天然复杂,clean 投影需 field-id→name 解析属更大改造,暂留)。**升级 1 条**:`calendar +suggest-time` 原 `rt.CallMCP` 吐 `result.recommendEventTimes[]` 且 `timeConflictAttendees:[null]` 噪音,改为 `CallMCPData`+`suggestTimeSlots`+`Output`:拍平 result、丢弃 null 冲突项,真机复验干净 `{suggestions:[{start,end}]}`(有真实冲突时才带 conflicts)。总数仍 569。**说明**:真机验证扫荡已进入边际收益递减区(明显 raw-verbose 的 wart 基本清完),剩余多为 minutes org-gated、写类、或输出已可接受。列出 12 条只读仍用 raw `rt.CallMCP` 的 smart(多为 minutes org-gated 或已验证 clean)。**升级 1 条**:`calendar +today` 原直吐 17 字段冗长事件(含完整 attendees 数组),改为 `CallMCPData`+复用 `+week` 的 `shortcutNextEventList/Start` 投影,真机复验干净输出 `{events:[{title,start,end,location,eventId}]}`(与 +week 一致 + location)。总数仍 569。剩余 raw-CallMCP 只读 smart:action-items/latest-minutes/transcript(minutes org-gated 无法真机验)、org/report-latest(已验 clean)、find-record/suggest-time/by-mobile/lookup/team(待验或权限受限)。
|
||||
|
||||
> 2026-07-09 批19(日历只读 smart 验证 + +free 保真度升级):`calendar +next-event` 真机正常(可读摘要「下一个日程:致拓 AI FDE 经验分享…」,但**忽略 --format json 只吐文本**,已知小瑕疵未改)。**升级 1 条**:`calendar +free` 终结步原 `rt.CallMCP` 直吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套,改为 `CallMCPData`+`freebusySlots`+`Output`,真机复验干净输出 `{who,userId,free,busy:[{start,end}]}`(董鑫阳 2026-07-10 忙 4 段)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批18(真机验证续 + +group-members 保真度升级):**验证正常**:`contact +org`(董鑫阳→模型算法/17人,3步链)、`drive +find-file`(干净投影 {dentryId,fileSize,name,type})。**后端受限(非 bug)**:`contact +team`(列部门成员 `PAT_MEDIUM_RISK_NO_PERMISSION`)、`chat +search-msg`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。**升级 1 条**:`chat +group-members` 终结步原用 `rt.CallMCP`(直吐原始冗长 `result.list[]` + memberAvatarMediaId + arguments/errorCode 噪音),改为 `CallMCPData`+`groupMemberProject`+`Output`,真机复验干净输出 `{count, members:[{name,nick,role,openDingtalkId}]}`(刘力/怒龙/群主…)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批17(修复批16 发现的 +at-me 投影):`chat +at-me` 原来因 `atMeMessageItems` 不认识真实两层嵌套 `result.conversationMessagesList[].messages[]` → 命中 fallback、直接吐原始结构。真机 dump 出真实结构(group 有 title/openConversationId/messages;message 有 sender/content/createTime/openConversationId),新增 `atMeFlattenGroups` 把各会话组拍平成单一消息列表、并把组的会话 title 下沉到每条消息。真机复验:43 条消息干净投影为 `{conversation,sender,text,time}`(如 conversation:"AI全栈"、sender:"龙衔")。总数仍 569。
|
||||
|
||||
> 2026-07-09 批16(只读 smart 真机验证扫荡 + 质量修复):真机跑一批时间/自身类只读 smart。**验证正常**:`calendar +today`(真实日程+参会人)、`calendar +week`(干净投影)、`todo +overdue`(空)、`attendance +my-attendance`(空)、`report +report-latest`("暂无日志"空路径)、`oa +my-initiated`(真实审批数据)。**修复 1 个输出 wart**:`chat +unread-chats` 每行都吐 `unread: null`——因 `unread_message_conversation_list` 根本不返回每会话未读数(在列表里即代表未读),改为「仅当 gateway 真返回未读数时才带 unread 字段」,真机复验输出已干净 `{conversationId,name}`。**已知待优化(未改)**:`chat +at-me` 返回 `result.conversationMessagesList[].messages[]` 冗长嵌套原始结构、未拍平成干净消息列表(功能正常,投影可再优化)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批15(质量修复 + resolveUser 排查,真机):**修复** `contact +dept-members` 消歧消息 `<red>` 标记泄漏——复用 `stripHighlightTags`(resolve_dept.go)在 name 提取处剥离,真机复验消息已干净("开放平台(666202009)、技术平台-开放平台研发(1085781688)…")。注:`dept_members.go` 本身容器解析(含 deptList)+数值 deptId 早已健壮,仅 name markup 未剥。**排查澄清(非 bug)**:`resolveUser` 对 `董鑫阳` 真机端到端正常(userId 202397、部门 模型算法);但对 `秋画` 这类联系人 `search_contact_by_key_word` 返回 name/userId 全 null(仅 openDingTalkId),resolveUser 正确报「没找到」而非瞎猜——这是钉钉数据模型现实(外部/受限联系人无 userId),非代码 bug。**已知限制**:按名解析仅对「搜索能返回 userId 的组织内成员」有效。总数仍 569。
|
||||
|
||||
> 2026-07-09 批14(真机验证续,需具体 ID 的只读 shortcut):**正向验证过**:`chat +my-groups`(98 真实群+投影)、`aitable +base-list`/`+list-tables`(真实 base/table)、`aitable +resolve-table`(单命中 通用→99dV75A、多候选消歧,容器 key `tables` 正确,无 deptList-class bug)。**后端权限受限、无法正向验证(非代码 bug)**:`chat +chat-messages`(`PAT_MEDIUM_RISK_NO_PERMISSION`,读会话消息需更高权限)、`minutes +*`(该 org 未开启 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。结论:可验证的 read/resolve shortcut 全部投影正确,仅批13 的 resolve-dept 有真 bug 已修。
|
||||
|
||||
> 2026-07-09 批13(真机验证 + bug 修复,登录态 corp「钉钉」):用登录态把批9-12 只读 shortcut 打真实后端。**正向验证过**:`doc +find-doc`(10 真实文档、投影干净)、`aitable +resolve-base`(多候选真实 baseId)、`mail +find-mail-user`(命中真实用户+邮箱)、`contact +resolve-dept`(修复后返回真实候选)。**真机抓到并修复 1 个真 bug**:`contact +resolve-dept` 原来对任何真实部门名都返回「未找到」——真实 `search_dept_by_keyword` 响应容器 key 是 **`deptList`**(agent 的探测清单漏了),且 `deptName` 带 `<red>…</red>` 高亮标记、`deptId` 是数值。已修:容器加 `deptList`、`stripHighlightTags` 去标记、deptId 数值 coerce 成串(`resolve_dept.go`),真机复验通过(开放平台→666202009、财务→846624121,名称干净)。**已知遗留(未改)**:`contact +dept-members` 的消歧提示消息里 `<red>` 标记未剥离(仅 cosmetic,功能正常)。总数仍 569,本批未加新命令。
|
||||
|
||||
> 2026-07-09 批12(多 agent 并行,dws 原生 resolver 层):再建 3 条「按名解析 ID」智能 shortcut——`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内本地名→tableId)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,**已修数值 ID 兼容**:deptId 为 JSON number 时 coerce 成串,非 string-only)。均 0/1/多候选消歧,对标 resolveUser 各资源版。这 3 条不对应具体 lark gap(是 dws 原生便利层),故 gap-buildable/covered-smart 矩阵计数不变(42/48),仅总数 566→569、smart 55→58。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批11(多 agent 并行):再建 3 条智能 shortcut——`aitable +resolve-base`(search_bases 按名解析 baseId + 0/1/多候选消歧)、`chat +chat-messages`(群/单聊会话消息 list_conversation_message_v2 / list_individual_chat_message,ExactlyOne 互斥 + 投影)、`mail +find-mail-user`(search_mail_users 按名搜企业邮箱联系人 + 投影)。文档全量同步 566/505/55(gap-buildable 49→42、covered-smart→48)。全绿。注:本批 app 回归首跑因并发负载 flaky FAIL 一次(80s),连跑 2 次稳定 PASS(71s)——非本次改动导致。
|
||||
|
||||
> 2026-07-09 批10(多 agent 并行):再建 3 条智能 shortcut——`chat +thread-replies`(list_topic_replies 拉话题回复 + sender/text/time 投影)、`todo +related-tasks`(get_user_todos_in_current_org 三角色 creator+executor+participant 并集 + taskId 去重 + 投影)、`doc +find-doc`(search_documents 关键词搜文档 + title/url/type/token 投影)。均以 helper 为 ground truth、0 编造。文档全量同步到 563/503/52(report.md/html、lark-alignment.md gap-buildable 49→44、covered-smart→46、comparison.html 重生成)。全量测试 + app 回归全绿。
|
||||
|
||||
> 2026-07-09 续跑增量(批9·手工):新建 3 条智能 shortcut——`minutes +detail`(单命令聚合一条听记 basic/summary/keywords/transcript/todos、partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换、去重校验+逐组聚合)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片≤20/批+跨 `aitable-helper` server fanout+合并)。均以 helper 为 ground truth。**同步刷新全部文档到 560/501/49**:`shortcut-report.md`、`shortcut-report.html`、`shortcut-lark-alignment.md`(gap-buildable 49→46、covered-smart 41→44)、重生成 `shortcut-comparison.html`。全量测试 + app 回归全绿。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
- 对齐基准:`/Users/dennis/Projects/larksuite/cli`(lark-cli 的 `shortcuts/` 框架,飞书 REST API)。
|
||||
- dws 执行底座:**钉钉 MCP**(粗粒度:一个 tool = 一个完整操作)。
|
||||
- 目标:把 lark 的 shortcut 能力**深度对齐每一个**到 dws,并补钉钉侧系统性能力。
|
||||
- 关键认知:lark 的"组合性"多源于飞书 API 细粒度(先查 token→id→再操作);钉钉 MCP 粗粒度,**lark 的多步在钉钉大量塌缩成 1:1(已被封装层覆盖)**。真正需要"编排"的是「按名解析 ID + 多工具串联 + 跨服务」——这些做成了 smart 层。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构与关键文件
|
||||
|
||||
```
|
||||
internal/shortcut/
|
||||
types.go # Shortcut / Flag / Risk 声明结构
|
||||
runner.go # RuntimeContext + mount(编译成cobra) + CallMCP/CallMCPData/Output + 校验/dry-run/风险确认
|
||||
validate.go # 跨字段校验 helper:MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll
|
||||
register.go # Register() / Commands() / All()
|
||||
shortcut_test.go# 框架单测
|
||||
builtin/
|
||||
builtin.go # blank-import 所有服务包 + smart 包;Commands() 汇总
|
||||
coverage_test.go # ★全量测试:TestAllShortcutsAssemble / TestAllToolLiteralsAreReal / TestAllHaveIntent / TestNoDuplicateCommands
|
||||
<service>/ # 19 个服务包:contact/chat/calendar/todo/doc/drive/mail/wiki/minutes/oa/report/attendance/aitable/sheet/devapp/ding/aisearch/live/devdoc
|
||||
<service>.go # 该服务的 1:1 封装 shortcut(var + init(){shortcut.Register(...)})
|
||||
smart/ # ★真·智能层(多步/编排/按名解析/跨服务)
|
||||
resolve.go # resolveUser(rt,name) 名→userId+消歧;contactUser{userID,name};extractUsers/userLabels
|
||||
dm.go lookup.go assign.go book.go free.go ... # 每条一个文件
|
||||
usage/ # P2 埋点:recorder.go(记形状不记值) stats.go command.go(dws shortcut list/stats/suggest/add)
|
||||
userdef/ # P2 自定义 shortcut YAML 运行时加载 loader.go
|
||||
|
||||
internal/app/legacy.go # 接线点:newLegacyPublicCommands 里 append builtin.Commands() + userdef.Load()
|
||||
internal/app/root.go # 装配 recordingToolCaller(埋点) + dws shortcut 命令
|
||||
internal/helpers/*.go # ★Ground truth:钉钉真实 MCP tool 名 + 参数(callMCPTool("tool",{...}))
|
||||
|
||||
docs/
|
||||
shortcut-plan.md # 总规划
|
||||
shortcut-p2-design.md # P2 自动沉淀设计
|
||||
shortcut-report.md / .html # 综合报告 + GSB
|
||||
shortcut-comparison.html # 逐条三方对照(dws vs lark vs 原生MCP)
|
||||
shortcut-lark-alignment.md # ★深度对齐矩阵(lark 361条逐条分析, 49 gap-buildable)
|
||||
shortcut-handoff.md # 本文件
|
||||
scripts/gen_shortcut_comparison.py # 生成三方对照 HTML
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 框架契约(写新 shortcut 必读)
|
||||
|
||||
一个 shortcut = 包级 `var X = shortcut.Shortcut{...}` + `func init(){ shortcut.Register(X) }`。
|
||||
|
||||
```go
|
||||
var SearchUser = shortcut.Shortcut{
|
||||
Service: "contact", // 顶层命令
|
||||
Command: "+search-user", // + 前缀,kebab-case
|
||||
Product: "contact", // MCP server id(默认=Service;注意跨 server,见坑位)
|
||||
Description: "...", // 一行
|
||||
Intent: "自然语言:做什么/何时用/副作用", // 每条必填(TestAllHaveIntent 强制)
|
||||
Risk: shortcut.RiskRead, // Read / Write / HighWrite(删除等,框架二次确认)
|
||||
Flags: []shortcut.Flag{{Name:"query", Type:shortcut.FlagString, Required:true, Desc:"...", Enum:[]string{...}}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error { return rt.RequireAll("query") }, // 可选
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { ... },
|
||||
}
|
||||
```
|
||||
|
||||
RuntimeContext 方法(`internal/shortcut/runner.go`/`validate.go`):
|
||||
- 读参数:`rt.Str/Bool/Int/StrSlice(name)`、`rt.Changed(name)`
|
||||
- **调 MCP 并打印**(终结步,1:1 封装用):`rt.CallMCP(tool, params) error`(用自身 Product)
|
||||
- **调 MCP 拿数据**(多步/投影用,不打印,可跨 server):`rt.CallMCPData(product, tool, params) (map[string]any, error)`
|
||||
- **投影输出**:`rt.Output(payload) error`(吃 --format/--jq/--fields)
|
||||
- 校验:`rt.MutuallyExclusive/AtLeastOne/ExactlyOne(flags...)`、`rt.RangeInt(flag,min,max)`、`rt.RequireAll(flags...)`
|
||||
- smart 复用:`resolveUser(rt, name) (contactUser, error)`(名→userId+消歧,在 smart/resolve.go)
|
||||
|
||||
对标 lark:`CallMCPData`≈`CallAPITyped`;`resolveUser`≈`ResolveOpenIDsTyped`;`rt.Output`≈`OutFormat`;`Validate helper`≈lark 的 MutuallyExclusive/AtLeastOne。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方法论(怎么高效批量建,屡试不爽)
|
||||
|
||||
**核心:多 agent workflow 并行 + helper 为 ground truth + 严格 skip + build/test 门禁。**
|
||||
|
||||
1. **每个 shortcut/服务一个 agent**,并行(`parallel(...)`)。
|
||||
2. **Ground truth 铁律**:tool 名和参数 key **只能逐字取自 `internal/helpers/<svc>.go` 的真实 `callMCPTool("tool",{params})` 调用点**,严禁编造。agent 必须先 Read+grep helper。
|
||||
3. **宁缺勿错**:拿不准的 tool/参数/结构 → **skip 并说明**,不瞎写(已多次证明 agent 会正确 skip,如 mail 无 email 字段)。
|
||||
4. **响应字段防御式解析**:返回结构无契约保证 → 多候选 key 探测(result/data/list/items + 字段别名),不硬编码。
|
||||
5. **不同 agent 写不同文件**(服务包 vs smart 包,或不同 service 文件)→ 无写冲突;**禁止 agent 改 builtin.go**(我事后统一维护 blank import)。
|
||||
6. **落地后统一**:`gofmt -w` → `go build ./...` → shortcut 全量测试 → 命名冲突用 rename 修(如 smart 的 `+approve` 撞 1:1 层 → 改 `+approve-by`)。
|
||||
7. **周期性 app 全量回归**(改多个服务文件后):`go test ./internal/app/...`(~73s)验证接线。
|
||||
|
||||
workflow 脚本模板见任意 `~/.claude/.../workflows/scripts/build-smart-*.js` 或 `upgrade-fidelity-*.js`(每次 Workflow 调用都存了盘,可 `{scriptPath}` 复用/改)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 已完成进展
|
||||
|
||||
### 5.1 覆盖层(511 条 1:1 封装 / 19 服务)
|
||||
chat89 aitable86 mail43 attendance36 doc33 minutes31 devapp30 sheet29 calendar24 drive24 oa20 todo18 wiki15 contact14 report7 ding7 aisearch3 live1 devdoc1。每条带自然语言 Intent。
|
||||
|
||||
### 5.2 智能层(~46 条 `internal/shortcut/smart/`)
|
||||
按名操作人/群、多步编排+失败回滚、时间/自身智能、跨服务、钉钉原生编排。已建(举例):
|
||||
`chat +dm/+send-to-group/+broadcast/+group-members/+at-me/+search-msg/+unread-chats`、`contact +lookup/+org/+team/+by-mobile/+dept-members`、`calendar +book(回滚)/+free/+today/+week/+next-event/+invite/+suggest-time/+reschedule/+cancel-event/+respond-event/+find-room`、`todo +assign/+assign-multi/+overdue/+todo-done/+remind/+created-todos`、`minutes +latest-minutes/+action-items/+transcript/+minutes-search/+detail/+replace-batch`、`oa +approve-by/+my-initiated`、`attendance +my-attendance`、`report +report-latest`、`aitable +find-record/+list-tables`、`doc +share-doc/+doc-append`、`wiki +wiki-new-doc`、`drive +find-file`、`mail +search-mail/+unread-mail`。
|
||||
|
||||
### 5.3 框架系统性能力(对齐 lark)
|
||||
`resolveUser`、`CallMCPData`、`rt.Output`、`Validate×5`。
|
||||
|
||||
### 5.4 保真度升级(~64 条封装:CallMCP→CallMCPData+投影+Output,对齐 lark 96% 输出投影)
|
||||
覆盖 contact/chat/calendar/todo/doc/drive/mail/wiki/aitable/oa/devapp/attendance/minutes/report/sheet 等服务的列表类命令。
|
||||
|
||||
### 5.5 P2 高频自动沉淀(差异化,lark 无)
|
||||
埋点(记形状不记值,默认关/opt-in DWS_USAGE_TRACKING=1) → `dws shortcut stats/suggest` → `dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 运行时 `userdef.Load()` 编译注册。**闭环端到端跑通。**
|
||||
|
||||
### 5.6 深度对齐矩阵
|
||||
`docs/shortcut-lark-alignment.md`:逐条分析 lark 361 条 → covered-1to1 144 / no-dingtalk-tool 127 / **gap-buildable 49** / covered-smart 41。
|
||||
|
||||
---
|
||||
|
||||
## 6. 如何继续(下一步 backlog)
|
||||
|
||||
1. **保真度升级剩余列表命令**(还有部分服务的 list 命令仍是裸 CallMCP):起 `upgrade-fidelity-N` workflow,每服务 agent 挑 1-2 个未升级(`grep 'rt.CallMCP('`)的列表读命令,改成 CallMCPData+投影+Output。范式见 `contact.go` 的 `searchUserProject`/`listRolesProject`。
|
||||
2. **补剩余 gap-buildable smart shortcut**(矩阵里 49 个,已建 ~20+):起 `build-smart-N` workflow(smart 包,不碰服务包)。剩余偏复杂(sheets/base 操作、消息富化、分片下载),谨慎、允许 skip。
|
||||
3. **每批**:gofmt→build→shortcut 测试→(改多文件后)app 回归→更新 `docs/shortcut-report.md`。
|
||||
4. **收尾**:把 `docs/shortcut-report.md/html` 的计数刷新到最终(部分 §2 测试数字可能还停在旧值 511/456,实际 ~557/~500),重跑 `python3 scripts/gen_shortcut_comparison.py`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键坑位(务必注意)
|
||||
|
||||
- **跨 server 路由**:有些 tool 不在本服务 server。已知:contact 花名册 tool 走 `hrmregister`;chat 部分 tool 走 `im`/`bot`;`query_check_record` 走 `attendance-wukong`(不是 attendance);wiki `create_file` 走 `doc` server。→ 这类必须用 `rt.CallMCPData("<真实server>", ...)`,不能用 `rt.CallMCP`(它按 shortcut.Product 路由会打错 server)。判断依据:helper 里是 `callMCPToolOnServer("<server>", ...)`。
|
||||
- **命名冲突**:smart 命令别撞 1:1 层(如 `+approve`→用 `+approve-by`,`+freebusy`→用 `+free`)。`TestNoDuplicateCommands` 会抓。
|
||||
- **参数别名**:aitable 查询关键词是 `keyword`(不是 query);todo 建待办用嵌套 `PersonalTodoCreateVO`;日程 create/update 时间是 ISO 字符串,而 list/busy 是毫秒——一切以 helper 调用点为准。
|
||||
- **中文 const tool 名**:minutes 录音 tool 是中文 `"执行听记指令-发起AI听记录音"`(const `listeningNoteCmdTool`)——真实,别当编造。
|
||||
- **测试真机验证**:token 有时效(`dws auth status` 看 expires),过期会报错非代码问题。真机跑命令时第一次有 catalog 发现 banner(stderr),结果 JSON 在后面,别用 `head` 截断。
|
||||
- **工具标签格式**(给 AI:本会话我多次误用错标签导致工具调用失败——务必用正确的 function-call 格式)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验证命令速查
|
||||
|
||||
```bash
|
||||
cd /Users/dennis/Projects/dingtalk-workspace/dingtalk-workspace-cli
|
||||
go build ./... # 编译
|
||||
gofmt -l internal/shortcut/ # 格式(应空)
|
||||
go test ./internal/shortcut/... # shortcut 全量(含 assemble/tool-real/intent/no-dup)
|
||||
go test ./internal/app/... # app 回归(~73s,改服务文件后必跑)
|
||||
go test ./internal/shortcut/builtin/ -run TestAllShortcutsAssemble -v 2>&1 | grep shortcuts= # 看总数
|
||||
|
||||
# 真机(需登录 dws auth login)
|
||||
DWS_USAGE_TRACKING=0 dws contact +lookup --name <真实姓名> # 智能多步示范
|
||||
DWS_USAGE_TRACKING=0 dws calendar +today # 时间智能
|
||||
DWS_USAGE_TRACKING=0 dws contact +search-user --query <名> # 投影输出示范
|
||||
```
|
||||
|
||||
测试口径:`TestAllShortcutsAssemble` = 假 Caller 拦截,给每条命令(含写/删)喂合成参数、走完解析→校验→组装 MCP 调用、断言 tool 真实无 panic(零副作用全量验证)。`TestAllToolLiteralsAreReal` = 所有 CallMCP tool 名比对 helper ground truth 防编造。
|
||||
|
||||
---
|
||||
|
||||
## 9. 未提交提醒
|
||||
|
||||
所有工作在 `feature/shortcut`,**未 commit**。建议尽快分语义化 commit 留存(框架 / 511封装 / smart层 / 保真度升级 / P2 / 文档)。
|
||||
@@ -0,0 +1,187 @@
|
||||
# lark-cli Shortcut 深度对齐矩阵
|
||||
|
||||
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
|
||||
|
||||
## 2026-07-13 最新源码复核
|
||||
|
||||
对比基线:
|
||||
|
||||
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
|
||||
- lark-cli:`main@e96c4fa5`
|
||||
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
|
||||
|
||||
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
|
||||
|
||||
### 当前命令面快照
|
||||
|
||||
| 指标 | 数量 | 说明 |
|
||||
|---|---:|---|
|
||||
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
|
||||
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
|
||||
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
|
||||
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
|
||||
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
|
||||
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
|
||||
|
||||
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
|
||||
|
||||
| 产品映射 | DWS | lark | 同名 |
|
||||
|---|---:|---:|---:|
|
||||
| aitable ↔ base | 82 | 87 | 31 |
|
||||
| calendar ↔ calendar | 23 | 10 | 3 |
|
||||
| chat ↔ im | 89 | 21 | 2 |
|
||||
| contact ↔ contact | 16 | 2 | 1 |
|
||||
| devapp ↔ apps | 30 | 63 | 3 |
|
||||
| doc ↔ doc | 19 | 14 | 1 |
|
||||
| drive ↔ drive | 9 | 26 | 3 |
|
||||
| mail ↔ mail | 10 | 21 | 0 |
|
||||
| minutes ↔ minutes | 13 | 9 | 1 |
|
||||
| sheet ↔ sheets | 2 | 42 | 0 |
|
||||
| todo ↔ task | 13 | 17 | 2 |
|
||||
| wiki ↔ wiki | 7 | 12 | 3 |
|
||||
|
||||
### 最新优先差距
|
||||
|
||||
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
|
||||
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
|
||||
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
|
||||
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
|
||||
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
|
||||
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
|
||||
|
||||
### 不建议机械追平
|
||||
|
||||
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
|
||||
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
|
||||
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
|
||||
|
||||
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
|
||||
|
||||
## 汇总(361 条 lark shortcut)
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`、task `+related-tasks` |
|
||||
| covered-smart | 48 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 7 → 剩 42,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(6)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` | read | dws 有 list-my-groups/list-all-conversations 原子 tool,但无 types 枚举+bot剥p2p降级、无 exclude-muted 客户端过滤、无字段投影 |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
|
||||
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
|
||||
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
|
||||
|
||||
### calendar → calendar(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
|
||||
|
||||
### doc (docs) → doc(2)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
|
||||
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
|
||||
|
||||
### drive → drive(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
|
||||
|
||||
### mail → mail(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
|
||||
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
|
||||
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
|
||||
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
|
||||
|
||||
### wiki → wiki(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
|
||||
|
||||
### minutes → minutes(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
|
||||
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
|
||||
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
|
||||
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
|
||||
|
||||
### base → aitable(10)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
|
||||
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
|
||||
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
|
||||
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
|
||||
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
|
||||
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
|
||||
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
|
||||
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
|
||||
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
|
||||
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
|
||||
|
||||
### sheets → sheet(14)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
|
||||
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
|
||||
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
|
||||
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
|
||||
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
|
||||
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
|
||||
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
|
||||
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
|
||||
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
|
||||
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
|
||||
|
||||
### apps → devapp(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
|
||||
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
|
||||
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
- **doc (docs)**: +history-revert
|
||||
- **drive**: +upload +search +inspect
|
||||
- **mail**: +triage
|
||||
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
|
||||
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
|
||||
@@ -0,0 +1,204 @@
|
||||
# DWS Shortcut P2 详细设计 — 高频场景自动沉淀为自定义 Shortcut
|
||||
|
||||
> 前置:P1 已交付静态声明式 shortcut 框架(`internal/shortcut/`),见 `docs/shortcut-plan.md`。
|
||||
> P2 目标:**观察用户高频使用 → 主动建议 → 一键沉淀为可复用的自定义 shortcut**,
|
||||
> 让 CLI 越用越顺手。这是 dws 相对 larksuite/cli 的差异化能力。
|
||||
|
||||
## 0. 体验闭环(一句话)
|
||||
|
||||
```
|
||||
用户反复敲 dws chat send_message --json '{"open_conversation_id":"cid_x","text":"..."}'
|
||||
│ (每次执行被静默记录到 ~/.dws/usage.jsonl)
|
||||
▼
|
||||
第 N 次后,dws 主动提示:
|
||||
💡 你已 12 次向「项目群」发消息,是否沉淀为 `dws chat +notify-team`?[y/N]
|
||||
│ y
|
||||
▼
|
||||
写入 ~/.dws/shortcuts/chat.notify-team.yaml
|
||||
▼
|
||||
之后:dws chat +notify-team --text "发布完成" ← 参数从一堆 JSON 收敛成一个 flag
|
||||
```
|
||||
|
||||
## 1. 总体架构
|
||||
|
||||
复用 P1 的 `Shortcut` 模型作为「编译目标」,新增四个部件:
|
||||
|
||||
| 部件 | 位置(建议) | 职责 |
|
||||
|------|-------------|------|
|
||||
| Usage 埋点 | `internal/shortcut/usage/recorder.go` | 每次 MCP 调用后追加一条 usage 记录 |
|
||||
| 模式挖掘 | `internal/shortcut/usage/miner.go` | 聚合 usage → 高频候选 + 打分 |
|
||||
| 主动提示 | `internal/shortcut/usage/nudge.go` | 命中候选时在命令收尾处提示 |
|
||||
| YAML 加载 | `internal/shortcut/userdef/loader.go` | 扫描 `~/.dws/shortcuts/*.yaml` → 编译成 `Shortcut` → 注册 |
|
||||
| 管理命令 | `internal/shortcut/usage`(cobra) | `dws shortcut list/suggest/add/rm/stats` |
|
||||
|
||||
数据流:
|
||||
|
||||
```
|
||||
CallMCP ──► recorder.Append(usage) [写侧,热路径,必须极轻]
|
||||
┌─► miner.TopCandidates() [读侧,suggest 时才算]
|
||||
~/.dws/usage.jsonl ─────────────────┤
|
||||
└─► nudge (命令收尾抽样触发)
|
||||
~/.dws/shortcuts/*.yaml ──► loader.Compile() ──► shortcut.Register() [启动时]
|
||||
```
|
||||
|
||||
## 2. Usage 埋点
|
||||
|
||||
### 2.1 采集点(choke point)
|
||||
|
||||
**首选**:装饰 `executor.Runner` / `edition.ToolCaller`。`internal/app/tool_caller_adapter.go`
|
||||
的 `CallTool(ctx, productID, toolName, args)` 是**所有 MCP 调用的唯一必经点**,天然拿到
|
||||
`(product, tool, args)` 三元组。用装饰器包一层即可,零侵入命令层:
|
||||
|
||||
```go
|
||||
type recordingCaller struct{ inner edition.ToolCaller }
|
||||
func (r recordingCaller) CallTool(ctx, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
res, err := r.inner.CallTool(ctx, product, tool, args)
|
||||
usage.Append(product, tool, args, err == nil) // 异步/带 recover,绝不影响主流程
|
||||
return res, err
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:P1 的 shortcut 也走这条 `CallMCP → helpers → deps.Caller`,所以内建 shortcut 的
|
||||
> 使用同样会被记录,可用于「哪些内建 shortcut 最受欢迎」的洞察。
|
||||
|
||||
### 2.2 记录内容(**隐私优先:记形状不记值**)
|
||||
|
||||
`~/.dws/usage.jsonl`,每行一条:
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-07-08T10:12:33+08:00",
|
||||
"product": "chat",
|
||||
"tool": "send_message",
|
||||
"arg_keys": ["open_conversation_id", "text"],
|
||||
"const_args": {"open_conversation_id": "cid_x"},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `arg_keys`:参数键集合(排序),用于识别「同一种调用形状」。
|
||||
- `const_args`:**仅收敛出的「疑似固定值」**(见 §3 挖掘时判定),写入时不保证脱敏,
|
||||
因此需要一层白名单/黑名单:`text/content/body/message` 等自由文本字段**永不入库**,
|
||||
只保留看起来像 ID/枚举的短值(长度阈值 + 无空格 + 非多行)。
|
||||
- 绝不记录:token、手机号、邮箱、文件内容、消息正文。用 `internal/logging/redact.go`
|
||||
已有的脱敏能力复核。
|
||||
|
||||
### 2.3 热路径约束
|
||||
|
||||
- 追加写用 `O_APPEND`,单行 < 1KB;失败静默(`recover` + debug 日志),**绝不阻断命令**。
|
||||
- 文件滚动:超过 N 行(如 5000)或 M 天,截断/归档,避免无限增长。
|
||||
- 开关:默认关闭(opt-in),环境变量 `DWS_USAGE_TRACKING=1` 开启(本地遥测即便只记形状也不应未经用户同意默认开启)。
|
||||
首次启用时在 `dws` 首跑给一次性告知(尊重知情)。
|
||||
|
||||
## 3. 模式挖掘
|
||||
|
||||
`dws shortcut suggest` 触发(也被 nudge 复用)。算法:
|
||||
|
||||
1. 读 usage.jsonl,按 `(product, tool, arg_keys)` 分桶。
|
||||
2. 对每桶:
|
||||
- `count` = 出现次数;低于阈值(默认 5)直接丢弃。
|
||||
- 对每个 arg_key,统计其值的分布:某值占比 ≥ 80% → 判定为**固定值**(进 `const_args`);
|
||||
否则判定为**可变参数**(沉淀后成为 flag)。
|
||||
- `recency` = 最近一次使用距今;越近权重越高。
|
||||
3. 打分 `score = count * log(distinct_days+1) * recencyDecay`,取 TopN。
|
||||
4. 生成候选 `Candidate{product, tool, fixed{...}, varFlags[...], score, samples}`。
|
||||
|
||||
输出示例(`dws shortcut suggest --format table`):
|
||||
|
||||
```
|
||||
候选 | 命令建议 | 依据 | 固定参数 | 可变flag
|
||||
#1 | chat +notify-team | 12 次 / 近 3 天 | open_conv=cid_x | text
|
||||
#2 | doc +new-agenda | 7 次 / 近 5 天 | template=agenda | title
|
||||
```
|
||||
|
||||
## 4. 主动提示(nudge)
|
||||
|
||||
- **时机**:命令成功收尾时(root `PersistentPostRunE`),**抽样**触发(如每 N 次调用或每次
|
||||
命中新达标候选时),避免打扰。仅在 TTY 交互态提示;非交互(Agent/管道/`--yes`)**不提示**。
|
||||
- **频控**:同一候选提示过一次被拒后,冷却期内不再提示(记 `~/.dws/shortcuts/.declined`)。
|
||||
- **交互**:
|
||||
```
|
||||
💡 检测到高频操作:你已 12 次向同一会话发消息。
|
||||
沉淀为快捷指令 dws chat +notify-team --text "..." ?
|
||||
[y] 沉淀 [n] 以后再说 [d] 不再提示此项
|
||||
```
|
||||
- y → 走 §5 生成 YAML;命名默认 `+<tool 去下划线的动宾>`,允许用户改名。
|
||||
|
||||
## 5. 自定义 Shortcut:YAML 格式与运行时加载
|
||||
|
||||
### 5.1 YAML schema(与 P1 `Shortcut` 一一对应)
|
||||
|
||||
`~/.dws/shortcuts/chat.notify-team.yaml`:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
service: chat
|
||||
command: "+notify-team"
|
||||
product: chat
|
||||
description: "发消息到 项目群(自动沉淀于 2026-07-08)"
|
||||
risk: write # 默认 read;send 类判定为 write
|
||||
source: auto # auto=沉淀 / manual=手写
|
||||
flags:
|
||||
- name: text
|
||||
type: string
|
||||
required: true
|
||||
desc: 消息内容
|
||||
execute:
|
||||
tool: send_message
|
||||
bind: # 参数绑定:常量 + ${flag} 模板
|
||||
open_conversation_id: "cid_x"
|
||||
text: "${text}"
|
||||
```
|
||||
|
||||
### 5.2 编译与注册
|
||||
|
||||
`userdef.Compile(yaml)` → `shortcut.Shortcut`,其 `Execute` 由 `bind` 生成:
|
||||
遍历 `bind`,`${flag}` 用 `rt.Str(flag)` 填充,常量原样,组装 params 后 `rt.CallMCP(tool, params)`。
|
||||
完全复用 P1 的 runner,不新增执行路径。
|
||||
|
||||
加载时机:`legacy.go` 装配点,在 `builtin.Commands()` 之后追加 `userdef.Commands()`,
|
||||
一起 merge。复用 `internal/plugin/loader.go` 已验证的「扫 `~/.dws/` 目录 + 挂 cobra」模式。
|
||||
|
||||
### 5.3 冲突与优先级
|
||||
|
||||
- 自定义 shortcut 命令名若与内建 shortcut / helper 冲突:**内建优先**,自定义重命名或跳过并告警。
|
||||
- `+` 前缀天然与 helper leaf 区分,冲突面小。
|
||||
|
||||
## 6. 管理命令面
|
||||
|
||||
```
|
||||
dws shortcut list # 列出内建 + 自定义 shortcut
|
||||
dws shortcut suggest [--min N] # 展示高频候选(不写入)
|
||||
dws shortcut add <candidate|--from-last> # 交互式/从最近一次调用沉淀
|
||||
dws shortcut rm <service> <+cmd> # 删除自定义 shortcut
|
||||
dws shortcut stats # usage 统计概览
|
||||
```
|
||||
|
||||
`dws shortcut` 本身作为一个新的顶层 utility 命令注册(对齐 `dws plugin`)。
|
||||
|
||||
## 7. 安全与隐私边界(红线)
|
||||
|
||||
1. **值不入库**:自由文本/正文/凭证一律不记;`const_args` 仅短 ID/枚举,且过 redact 复核。
|
||||
2. **可关可清**:`DWS_USAGE_TRACKING=0` 关闭;`dws shortcut stats --purge` 清空 usage。
|
||||
3. **执行白名单**:自定义 shortcut 的 `execute.tool` 必须解析到合法 MCP server(过
|
||||
`internal/security` endpoint 白名单),禁止指向任意 endpoint。
|
||||
4. **不自动执行**:沉淀只生成命令定义,**绝不**自动发起写操作;写类 shortcut 仍受 P1 的
|
||||
risk 确认约束。
|
||||
5. **知情**:首次开启埋点一次性告知;提示可永久关闭。
|
||||
|
||||
## 8. 实现顺序(P2 分步,便于 loop 推进)
|
||||
|
||||
- P2-1 usage 埋点:`recordingCaller` 装饰器 + `usage.Append` + jsonl 写 + 开关 + 脱敏白名单。
|
||||
- P2-2 `dws shortcut stats` / `list`:先让数据可见,验证埋点质量。
|
||||
- P2-3 miner + `dws shortcut suggest`:离线挖掘与打分。
|
||||
- P2-4 userdef YAML 加载 + `Compile` + 注册 + 冲突处理(打通「手写 YAML 也能用」)。
|
||||
- P2-5 `dws shortcut add`(从候选/最近调用沉淀)。
|
||||
- P2-6 nudge 主动提示(最后做,最谨慎,默认保守频控)。
|
||||
|
||||
## 9. 待决策点(需产品确认)
|
||||
|
||||
1. 埋点默认开还是默认关?→ **修订后:默认关(opt-in)+ 开启后首跑一次性告知**(原设计默认开,反思后改为 opt-in:自主 agent 不应单方面默认开本地遥测)
|
||||
(`DWS_USAGE_TRACKING=0` / 配置项关闭;`dws shortcut stats --purge` 清空)。实现时以此为准。
|
||||
2. `const_args` 允许记录的字段白名单粒度?(保守起步:只记形如 `*_id/*Id/type/status` 的短值)
|
||||
3. nudge 触发频率与渠道?(建议:仅 TTY、命中新候选时、每候选一生仅一次)
|
||||
4. 自定义 shortcut 是否需要跨设备同步?(v1 先本地 `~/.dws/`,同步留待后续)
|
||||
@@ -0,0 +1,127 @@
|
||||
# DWS Shortcut 能力 — 总体规划
|
||||
|
||||
> 目标:为 dws 引入一套 **声明式高保真命令(Shortcut)** 能力,对齐 larksuite/cli 的 `+command`
|
||||
> 体验(如 `lark-cli contact +search-user`),并在此之上做 dws 差异化:**基于用户高频使用场景,
|
||||
> 主动把常用操作沉淀为自定义 shortcut**。
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
dws 当前的命令有三类来源:
|
||||
|
||||
1. **MCP 运行时动态发现** —— `dws mcp <service> <tool> --json '{...}'`,通用但裸、参数需手拼 JSON。
|
||||
2. **`internal/helpers/` 产品命令** —— 手写 cobra 命令,体验好但每个都从零写、缺统一框架。
|
||||
3. **`internal/registry/recipes.yaml`** —— 多步工作流的静态描述。
|
||||
|
||||
痛点:想新增一个「精选、参数友好、带 dry-run/format/身份」的单命令,只能手写 helper,
|
||||
没有统一的声明式框架,重复劳动多、一致性差。larksuite 的 shortcut 框架正好解决这一层。
|
||||
|
||||
## 2. 与 larksuite/cli 的架构差异(关键)
|
||||
|
||||
| 维度 | larksuite/cli | dws-cli |
|
||||
|------|---------------|---------|
|
||||
| 命令来源 | 静态硬编码 Go shortcut | MCP 运行时动态发现 + helpers |
|
||||
| 调用底座 | Lark SDK 直连 API | MCP JSON-RPC(`executor.Runner`) |
|
||||
| 精选命令层 | `shortcuts/`(200+ 声明式) | `internal/helpers/`(手写 cobra) |
|
||||
| 全局 flag | 框架注入 | root 已内建 `--format/--dry-run/--jq/--yes/--fields/--profile` |
|
||||
|
||||
**结论**:不能直接搬代码。移植的是 shortcut 的**声明式设计**,执行底座换成 dws 的
|
||||
`executor.Runner`,全局能力复用 dws 已有的 output/safety/auth。
|
||||
|
||||
## 3. 分期目标
|
||||
|
||||
### P1 — 静态声明式框架(本期,正在做)
|
||||
- 新建独立模块 `internal/shortcut/`(零侵入现有 helpers)。
|
||||
- `types.go`:`Shortcut` / `Flag` / `RuntimeContext` 声明层。
|
||||
- `runner.go`:把 `Shortcut` 编译成 `*cobra.Command`,串起 flag 注册 → 校验 → dry-run →
|
||||
`executor.Runner.Run` → `output.WriteCommandPayload`。
|
||||
- `register.go`:按 service 分组产出命令,在 `internal/app/legacy.go` 装配点 merge 进命令树。
|
||||
- 样板命令 `contact +search-user`:打通 MCP 执行 / format / dry-run / 身份,作为后续命令模板。
|
||||
- 交付判据:`dws contact +search-user --help`、`--dry-run` 正常;`go build` / `go test` 通过。
|
||||
|
||||
### P2 — 高频场景自动沉淀(后续,先设计再实现)
|
||||
- **使用埋点**:命令执行入口记录 `~/.dws/usage.jsonl`(只记参数形状,不记敏感值)。
|
||||
- **模式挖掘**:`dws shortcut suggest` 聚合高频 `(service, tool, 固定参数组合)`。
|
||||
- **主动沉淀**:命中候选时提示用户,一键写入 `~/.dws/shortcuts/*.yaml`(声明式,与 P1 结构对应)。
|
||||
- **运行时加载**:`register.go` 额外扫描 `~/.dws/shortcuts/*.yaml` 动态注册,复用
|
||||
`internal/plugin/loader.go` 已验证的「从 `~/.dws/` 加载并挂 cobra 命令」模式。
|
||||
|
||||
## 4. 落地方式(P1)
|
||||
|
||||
采用**独立模块 + 装配点 merge**,不改 helpers 内部:
|
||||
|
||||
```
|
||||
internal/shortcut/
|
||||
types.go # Shortcut / Flag / RuntimeContext
|
||||
runner.go # 声明式→cobra 编译 + 执行管道
|
||||
register.go # Commands(runner) []*cobra.Command,按 service 分组
|
||||
contact/
|
||||
search_user.go # 样板:var SearchUser = shortcut.Shortcut{...}
|
||||
shortcuts.go # Shortcuts() []shortcut.Shortcut
|
||||
```
|
||||
|
||||
接线:`internal/app/legacy.go: newLegacyPublicCommands` 里,
|
||||
`helpers.NewPublicCommands(runner)` 之后追加 `shortcut.Commands(runner)`,
|
||||
一起走 `mergeTopLevelCommands`(同名 service 命令自动合并,`+xxx` 作为其子命令)。
|
||||
|
||||
复用点:
|
||||
- 执行:`executor.NewHelperInvocation` + `runner.Run`(与 helper 完全一致的调用路径)。
|
||||
- 输出:`output.WriteCommandPayload(cmd, resp, output.FormatJSON)`(自动吃 root 的 `--format/--jq/--fields`)。
|
||||
- dry-run:读 root `--dry-run`,置 `Invocation.DryRun`,由 runner 返回请求预览。
|
||||
- 身份/安全:复用 `--profile`、`internal/safety`(高风险 `--yes` 确认)。
|
||||
|
||||
## 5. Shortcut 声明模型(草案)
|
||||
|
||||
```go
|
||||
type Shortcut struct {
|
||||
Service string // "contact" → 顶层命令
|
||||
Command string // "+search-user" → 子命令(保留 + 前缀,对齐 larksuite)
|
||||
Description string
|
||||
Risk string // read | write | high-risk-write
|
||||
Flags []Flag
|
||||
Validate func(*RuntimeContext) error
|
||||
Execute func(*RuntimeContext) error // 必填;内部调 rt.CallMCP(...)
|
||||
}
|
||||
|
||||
type Flag struct {
|
||||
Name, Type, Default, Desc string
|
||||
Required bool
|
||||
Enum []string
|
||||
}
|
||||
```
|
||||
|
||||
`RuntimeContext` 给 Execute 提供:flag 读取(`Str/Bool/Int/StrSlice/Changed`)、
|
||||
`CallMCP(product, tool, params)`(内部 `runner.Run`)、`Output(payload)`、`DryRun()`。
|
||||
|
||||
## 6. 风险与边界
|
||||
|
||||
- **与 `dws mcp` 通道的边界**:shortcut 是「人工精选的薄封装」,不替代通用 MCP 通道;
|
||||
一个 tool 可以既能 `dws mcp` 直调,也能有 shortcut。
|
||||
- **自定义 shortcut 安全(P2)**:YAML 的 `execute` 若允许任意 MCP 调用,需过
|
||||
`internal/security` 的 endpoint 白名单,且沉淀的参数值要脱敏。
|
||||
- **命名冲突**:`+` 前缀天然与现有 leaf 命令区分,降低与 helper 命令的冲突面。
|
||||
- **edition 差异**:oss / enterprise 的可用 service 不同,注册时按 edition 过滤(后续接入)。
|
||||
|
||||
## 7. 进度看板
|
||||
|
||||
- [x] P1-1 types.go — `Shortcut` / `Flag` / `Risk` 声明层
|
||||
- [x] P1-2 runner.go — `RuntimeContext` + `mount` 编译 + 校验/确认/dry-run;`CallMCP` 委托 `helpers.CallMCPToolOnServer`(复用错误分类/输出/dry-run)
|
||||
- [x] P1-3 register.go + `internal/shortcut/contact/search_user.go` + `builtin` 聚合包
|
||||
- [x] P1-4 接线 `legacy.go`(append 到 `mergeTopLevelCommands`)+ build/test 全绿
|
||||
- [ ] P2 设计文档(进行中)
|
||||
|
||||
### P1 落地实证(已验证)
|
||||
|
||||
- `dws contact +search-user --help`:命令挂载,继承全局 `--format/--dry-run/--jq/...`。
|
||||
- 必填校验:不传 `--query` → 结构化 validation 错误。
|
||||
- `--dry-run`:走 helpers 路径输出 `[DRY-RUN]` 预览(tool + 参数)。
|
||||
- 命令树 merge:`+search-user` 与现有 `user/dept/label/relation` 共存,`contact user search` 未受影响。
|
||||
- 测试:`internal/shortcut` 单测通过;`internal/app` 全量回归通过。
|
||||
|
||||
### P1 关键决策记录
|
||||
|
||||
- **执行底座复用 helpers 而非裸 `runner.Run`**:`CallMCP` 委托 `helpers.CallMCPToolOnServer(product, tool, params)`,
|
||||
一步获得错误分类(auth/PAT/业务)+ 格式化输出 + dry-run,避免重造劣质输出层。代价是
|
||||
`internal/shortcut → internal/helpers` 的单向依赖(无环)。后续若要 shortcut 做多调用编排/输出重塑,
|
||||
再补一个返回原始 payload 的 `CallMCPRaw`。
|
||||
- **避免 import 环**:service 包(contact)import 核心 `shortcut` 包并在 `init()` 注册;
|
||||
`builtin` 聚合包 blank-import 各 service 包;`app` 只依赖 `builtin`。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# Shortcut 真实测试跟进清单
|
||||
|
||||
生成时间:`2026-07-15T16:58:39`
|
||||
|
||||
来源:`docs/shortcut-real-read-results.json` 与 `docs/shortcut-real-write-results.json`。
|
||||
|
||||
口径:记录真实后端测试中需要继续定位的 case,用于 CR 和问题分派;Agent 使用入口以公开 shortcut catalog 和产品 skill 为准。
|
||||
|
||||
总计:156 条。
|
||||
|
||||
| # | suite | shortcut | risk | status | category | fixability | 处理依据 |
|
||||
|---:|---|---|---|---|---|---|---|
|
||||
| 1 | read | `aitable +base-get-primary-doc-id` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 2 | read | `aitable +chart-share-get` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 3 | read | `aitable +dashboard-share-get` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 4 | read | `aitable +export-data` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 5 | read | `aitable +record-primary-doc-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 6 | read | `aitable +role-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 7 | read | `aitable +workflow-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 8 | read | `aitable +workflow-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 9 | read | `attendance +get-class` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 10 | read | `attendance +get-global-setting` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 11 | read | `attendance +get-group` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 12 | read | `attendance +get-group-filtered` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 13 | read | `attendance +get-leave-balance` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 14 | read | `attendance +list-report-columns` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 15 | read | `attendance +query-report-leave` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 16 | read | `calendar +find-room` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 17 | read | `calendar +room-find` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 18 | read | `chat +category-list-conversations` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 19 | read | `chat +chat-get-by-id` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 20 | read | `chat +chat-members-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 21 | read | `chat +chat-messages` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 22 | read | `chat +messages-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 23 | read | `chat +messages-resource-url` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 24 | read | `chat +search-msg` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 25 | read | `chat +thread-replies` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 26 | read | `contact +get-roster` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 27 | read | `contact +list-roster-fields` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 28 | read | `devapp +credentials-get` | read | held | held | manual-approval | 高风险或无安全目标,需人工逐项授权后执行。 |
|
||||
| 29 | read | `drive +download` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 30 | read | `drive +list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 31 | read | `minutes +action-items` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 32 | read | `minutes +latest-minutes` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 33 | read | `minutes +minutes-search` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 34 | read | `minutes +transcript` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 35 | read | `oa +done-approvals` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 36 | read | `oa +pending` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 37 | read | `report +report-latest` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 38 | read | `todo +due-today` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 39 | read | `todo +related-tasks` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 40 | read | `wiki +node-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 41 | read | `wiki +resolve-space` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 42 | read | `wiki +space-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 43 | write | `aitable +advperm-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 44 | write | `aitable +advperm-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 45 | write | `aitable +attachment-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 46 | write | `aitable +base-copy` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 47 | write | `aitable +base-delete` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 48 | write | `aitable +base-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 49 | write | `aitable +chart-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 50 | write | `aitable +chart-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 51 | write | `aitable +chart-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 52 | write | `aitable +dashboard-arrange` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 53 | write | `aitable +dashboard-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 54 | write | `aitable +dashboard-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 55 | write | `aitable +dashboard-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 56 | write | `aitable +field-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 57 | write | `aitable +field-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 58 | write | `aitable +form-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 59 | write | `aitable +form-field-hide` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 60 | write | `aitable +form-field-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 61 | write | `aitable +form-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 62 | write | `aitable +form-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 63 | write | `aitable +import-data` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 64 | write | `aitable +import-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 65 | write | `aitable +record-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 66 | write | `aitable +record-primary-doc-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 67 | write | `aitable +record-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 68 | write | `aitable +record-upsert` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 69 | write | `aitable +role-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 70 | write | `aitable +role-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 71 | write | `aitable +role-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 72 | write | `aitable +section-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 73 | write | `aitable +section-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 74 | write | `aitable +section-move-node` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 75 | write | `aitable +section-rename` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 76 | write | `aitable +section-reorder` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 77 | write | `aitable +table-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 78 | write | `aitable +table-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 79 | write | `aitable +view-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 80 | write | `aitable +view-duplicate` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 81 | write | `aitable +view-lock` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 82 | write | `aitable +view-set-fill-color-rule` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 83 | write | `aitable +view-set-frozen-cols` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 84 | write | `aitable +view-set-row-height` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 85 | write | `aitable +view-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 86 | write | `aitable +workflow-disable` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 87 | write | `aitable +workflow-enable` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 88 | write | `attendance +boss-check` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 89 | write | `attendance +create-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 90 | write | `attendance +create-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 91 | write | `attendance +import-schedule` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 92 | write | `attendance +save-leave-balance` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 93 | write | `attendance +update-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 94 | write | `attendance +update-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 95 | write | `attendance +update-group-members` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 96 | write | `attendance +update-leave-type` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 97 | write | `calendar +respond-event` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 98 | write | `chat +category-add-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 99 | write | `chat +category-remove-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 100 | write | `chat +chat-add-bot` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 101 | write | `chat +chat-audit-join` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 applicantUid/inviterUid;真实后端仍报 applicantUid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 102 | write | `chat +chat-mute-member` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 103 | write | `chat +chat-quit` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 104 | write | `chat +chat-remove-bot` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 105 | write | `chat +chat-role-remove` | high-risk-write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 106 | write | `chat +chat-role-remove-user` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 107 | write | `chat +chat-transfer-owner` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 108 | write | `chat +chat-update-icon` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 109 | write | `chat +chat-update-settings` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 110 | write | `chat +conversation-clear-messages` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 111 | write | `chat +conversation-clear-red-point` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 112 | write | `chat +conversation-hide` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 113 | write | `chat +conversation-mark-read` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 114 | write | `chat +conversation-mark-unread` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 115 | write | `chat +conversation-mute` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 116 | write | `chat +conversation-mute-at-all` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 117 | write | `chat +conversation-mute-red-envelope` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 118 | write | `chat +conversation-set-top` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 119 | write | `chat +messages-add-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 120 | write | `chat +messages-add-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 121 | write | `chat +messages-batch-recall-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 122 | write | `chat +messages-batch-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 123 | write | `chat +messages-combine-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 124 | write | `chat +messages-create-text-emotion` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 125 | write | `chat +messages-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 126 | write | `chat +messages-forward-topic` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 127 | write | `chat +messages-recall` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 128 | write | `chat +messages-recall-by-bot` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 129 | write | `chat +messages-remove-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 130 | write | `chat +messages-remove-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 131 | write | `chat +messages-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 132 | write | `chat +messages-send-card` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 receiverUid;真实后端仍报 receiverUid/openConversationId 为空,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 133 | write | `chat +messages-set-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 134 | write | `chat +messages-set-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 135 | write | `chat +messages-unset-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 136 | write | `chat +messages-unset-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 137 | write | `devapp +event-subscribe` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 138 | write | `devapp +event-unsubscribe` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 139 | write | `devapp +permission-add` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 140 | write | `devapp +permission-remove` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 141 | write | `devapp +robot-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 142 | write | `devapp +robot-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 143 | write | `devapp +robot-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 144 | write | `devapp +security-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 145 | write | `devapp +version-create` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 146 | write | `devapp +version-publish` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 147 | write | `ding +send-by-message` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 148 | write | `doc +comment-create-inline` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 149 | write | `doc +template-apply` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 150 | write | `minutes +record-pause` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 151 | write | `minutes +record-resume` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 152 | write | `minutes +record-stop` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 153 | write | `oa +approve-by` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 154 | write | `wiki +node-copy` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 155 | write | `wiki +node-move` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 156 | write | `wiki +wiki-new-doc` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DWS Shortcut 完成情况报告</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#161d2c;--ink:#e6edf6;--muted:#93a1b5;--line:#26314a;
|
||||
--blue:#4f9cff;--green:#3fb950;--yellow:#d5a429;--red:#f25c5c;--accent:#6ea8fe;--purple:#a371f7}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:linear-gradient(180deg,#0d1220,#0f1420);color:var(--ink);
|
||||
font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:0 0 80px}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:0 22px}
|
||||
header{padding:52px 22px 28px;text-align:center;border-bottom:1px solid var(--line);
|
||||
background:radial-gradient(1200px 300px at 50% -60px,rgba(79,156,255,.16),transparent)}
|
||||
h1{font-size:29px;margin:0 0 8px;letter-spacing:.5px}
|
||||
.sub{color:var(--muted);font-size:14px}
|
||||
.stats{display:flex;gap:13px;justify-content:center;flex-wrap:wrap;margin:26px 0 4px}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 20px;min-width:118px}
|
||||
.stat .n{font-size:27px;font-weight:700;color:var(--accent)}
|
||||
.stat .l{color:var(--muted);font-size:12.5px;margin-top:2px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--line)}
|
||||
h2 .ico{color:var(--accent);margin-right:8px}
|
||||
h3{font-size:15.5px;margin:22px 0 9px;color:var(--accent)}
|
||||
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13.5px;background:var(--card);
|
||||
border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{background:#1b2536;color:var(--muted);font-weight:600;font-size:12.5px}
|
||||
tr:last-child td{border-bottom:none}
|
||||
td.c,th.c{text-align:center}
|
||||
.num{color:var(--accent);font-weight:700;text-align:center}
|
||||
.g{color:var(--green);font-weight:700}.s{color:var(--yellow);font-weight:700}.b{color:var(--red);font-weight:700}
|
||||
.ok{color:var(--green)}.star{color:var(--yellow)}
|
||||
code{background:#0c1120;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12.5px;color:#cfe0ff}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:15px 18px;margin:13px 0}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.layer{border-radius:12px;padding:16px 18px}
|
||||
.l-wrap{background:linear-gradient(180deg,rgba(79,156,255,.08),transparent);border:1px solid #234b6b}
|
||||
.l-smart{background:linear-gradient(180deg,rgba(163,113,247,.10),transparent);border:1px solid #4a3a6b}
|
||||
.layer h3{margin-top:0}
|
||||
.pill{display:inline-block;background:#12283a;color:#7fc6ff;border:1px solid #234b6b;border-radius:6px;padding:1px 7px;font-size:12px;margin:2px 3px 2px 0}
|
||||
.flow{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:13px;color:var(--muted)}
|
||||
.flow b{color:var(--ink)}.flow .arw{color:var(--purple)}
|
||||
.concl{background:linear-gradient(90deg,rgba(63,185,80,.10),transparent);border-left:3px solid var(--green);padding:14px 18px;border-radius:8px;margin-top:16px}
|
||||
.keyfind{background:linear-gradient(90deg,rgba(213,164,41,.10),transparent);border-left:3px solid var(--yellow);padding:14px 18px;border-radius:8px;margin:14px 0}
|
||||
footer{color:var(--muted);text-align:center;font-size:12.5px;margin-top:40px}
|
||||
a{color:var(--accent)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>DWS Shortcut 完成情况报告</h1>
|
||||
<div class="sub">对齐基准 larksuite/cli · 执行底座 钉钉 MCP · 随 loop 持续更新</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n">366</div><div class="l">shortcut 总数</div></div>
|
||||
<div class="stat"><div class="n">298</div><div class="l">1:1 封装层</div></div>
|
||||
<div class="stat"><div class="n">68</div><div class="l">真·智能层</div></div>
|
||||
<div class="stat"><div class="n">16</div><div class="l">覆盖服务</div></div>
|
||||
<div class="stat"><div class="n">0</div><div class="l">失败/panic/编造</div></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
|
||||
<h2><span class="ico">①</span>做了什么:两个层次</h2>
|
||||
<p>诚实区分——shortcut 分两层,价值定位不同,不混为一谈。</p>
|
||||
<div class="two">
|
||||
<div class="layer l-wrap">
|
||||
<h3>🔵 1:1 封装层 · 298 条</h3>
|
||||
<div style="color:var(--muted);font-size:13.5px">一个 shortcut ≡ 一个 MCP tool。把裸 <code>dws mcp <svc> <tool> --json '{…}'</code> 收敛成命名 flag,附校验/风险确认/Intent。</div>
|
||||
<div style="margin:10px 0"><b>价值</b>:DX 与 AI-agent 可发现性,<b>不是新能力</b>。</div>
|
||||
<div><span class="pill">命名 flag</span><span class="pill">required/enum 校验</span><span class="pill">风险确认</span><span class="pill">自然语言 Intent</span><span class="pill">dry-run/format</span></div>
|
||||
</div>
|
||||
<div class="layer l-smart">
|
||||
<h3>🟣 真·智能层 · 68 条</h3>
|
||||
<div style="color:var(--muted);font-size:13.5px">照 lark-cli 范式的多步/编排/智能,<b>不是 1:1</b>。框架新增 <code>CallMCPData</code>(多步取数,对标 lark <code>CallAPITyped</code>)+ <code>resolveUser</code>(名→ID,对标 <code>ResolveOpenIDsTyped</code>)。</div>
|
||||
<div style="margin:10px 0"><b>价值</b>:<b>这才是「shortcut 作为新能力」</b>。</div>
|
||||
<div><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">按名解析+消歧</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">多工具编排</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">失败回滚</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">跨服务</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><span class="ico">②</span>真·智能层 68 条明细(节选)</h2>
|
||||
<table>
|
||||
<thead><tr><th>shortcut</th><th>多步/智能逻辑</th><th class="c">验证</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>chat +dm --to <名></code></td><td>搜人→解析 userId→发单聊;多人消歧</td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>contact +lookup --name <名></code></td><td>搜人→解析→取完整资料</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>todo +assign --to <名></code></td><td>解析人→建待办并设执行人</td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>contact +org --name <名></code></td><td>解析人→取 deptId→查部门详情(3 步)</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>contact +team --name <名></code></td><td>解析人→取部门→列部门成员</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +free --who <名></code></td><td>解析人→查其时段忙闲</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>calendar +book [--with <名CSV>]</code></td><td>建日程→按名加参与者→<b>失败回滚删日程</b></td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>calendar +invite --event --with</code></td><td>解析多人→加入已有日程</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +suggest-time --with</code></td><td>解析多人→推荐可开会时间</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +today</code></td><td>算今天范围→列我今天日程</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>calendar +next-event</code></td><td>近 7 天→取最近一个日程</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +reschedule --event</code></td><td>查日程详情→改时间</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +send-to-group --group <群名></code></td><td>按群名搜群→消歧→发消息</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +group-members --group <群名></code></td><td>搜群→列群成员</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +broadcast --to <名CSV></code></td><td>多名逐一解析→群发单聊,失败汇总</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>todo +todo-done --task <关键词></code></td><td>列我待办→按标题匹配→标完成</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>todo +remind --task --at</code></td><td>给自己建带提醒的待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>wiki +wiki-new-doc --space <名></code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +share-doc --to <名> --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card">
|
||||
<b>质量亮点(agent 严守 ground truth)</b>:<code>+send-to-group</code> 纠正了「按群名搜群」的正确工具(<code>search_groups</code> 而非按成员昵称的 <code>search_common_groups</code>);<code>+wiki-new-doc</code> 发现 <code>create_file</code> 在 doc server 并正确跨服务路由;<code>+mail-to</code> 因钉钉无 email 字段<b>主动 skip 拒绝编造</b>。
|
||||
</div>
|
||||
|
||||
<h2><span class="ico">③</span>测试验证(零副作用全量)</h2>
|
||||
<p>写/删命令不能真跑,用<b>假 Caller 拦截</b>——每条命令走完「解析→校验→确认→组装 MCP 调用」,捕获组装出的 <code>(product,tool,params)</code>,不真发网络。</p>
|
||||
<table>
|
||||
<thead><tr><th>验证项</th><th>范围</th><th>结果</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>go build ./...</code> / gofmt / vet</td><td>全仓</td><td class="ok">✅ 0 告警</td></tr>
|
||||
<tr><td>TestAllShortcutsAssemble</td><td>全部 366</td><td class="ok">✅ 322 组装真实MCP · 44 自校验 · 0 失败/panic</td></tr>
|
||||
<tr><td>TestAllToolLiteralsAreReal</td><td>tool 字面量</td><td class="ok">✅ 0 编造(比对 helper ground truth)</td></tr>
|
||||
<tr><td>TestAllHaveIntent</td><td>全部 366</td><td class="ok">✅ 每条均有自然语言描述</td></tr>
|
||||
<tr><td>TestNoDuplicateCommands</td><td>全部</td><td class="ok">✅ 无重复 · 命名规范</td></tr>
|
||||
<tr><td>usage / userdef 单测</td><td>埋点/沉淀</td><td class="ok">✅ 全通过</td></tr>
|
||||
<tr><td>app 包全量回归</td><td>internal/app</td><td class="ok">✅ ~72s 通过(未破坏现有命令)</td></tr>
|
||||
<tr><td>智能层真机验证(9 批)</td><td>只读/解析类 20+ 条</td><td class="ok">✅ 端到端返回真实数据、投影正确</td></tr>
|
||||
<tr><td>真机抓修真实 bug</td><td>合成测试盖不住的投影/解析偏差</td><td class="ok">✅ 修复 8 处(resolve-dept/at-me/group-members/free/today/suggest-time/unread-chats/dept-members)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="color:var(--muted);font-size:13px">真机验证补齐了 assemble 测试的盲区:合成响应验证不了「防御式投影是否匹配真实响应结构」。9 批真机验证抓到并修复 8 处解析/投影偏差(如 <code>+resolve-dept</code> 漏了真实容器 key <code>deptList</code>、<code>+at-me</code> 未拍平嵌套、<code>+today/+free</code> 直吐冗长 raw)。少数命令(chat 会话消息读、minutes)因 org/PAT 权限受限无法真机跑通,组装链路仍由 assemble 测试覆盖。</p>
|
||||
|
||||
<h2><span class="ico">④</span>效果评估 GSB(vs lark-cli)</h2>
|
||||
<h3>4.1 能力覆盖 GSB(按 dws 实际暴露的 MCP tool 数 = helper∪shortcut,非 shortcut 数)</h3>
|
||||
<table>
|
||||
<thead><tr><th>lark 服务</th><th class="c">lark</th><th>dws</th><th class="c">dws</th><th class="c">GSB</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>im</td><td class="c">21</td><td>chat</td><td class="c">95</td><td class="c g">G</td><td>群/消息/机器人更全</td></tr>
|
||||
<tr><td>mail</td><td class="c">21</td><td>mail</td><td class="c">43</td><td class="c g">G</td><td>覆盖更广</td></tr>
|
||||
<tr><td>doc</td><td class="c">14</td><td>doc</td><td class="c">34</td><td class="c g">G</td><td>块级读写更细</td></tr>
|
||||
<tr><td>minutes</td><td class="c">14</td><td>minutes</td><td class="c">25</td><td class="c g">G</td><td>录音/说话人更全</td></tr>
|
||||
<tr><td>calendar</td><td class="c">12</td><td>calendar</td><td class="c">24</td><td class="c g">G</td><td>会议室/ACL 更全</td></tr>
|
||||
<tr><td>contact</td><td class="c">2</td><td>contact</td><td class="c">15</td><td class="c g">G</td><td>部门/角色/花名册更全</td></tr>
|
||||
<tr><td>wiki</td><td class="c">12</td><td>wiki</td><td class="c">16</td><td class="c g">G</td><td>略优</td></tr>
|
||||
<tr><td>base</td><td class="c">87</td><td>aitable</td><td class="c">79</td><td class="c s">S</td><td>持平;helper 仅 16,<b>shortcut 补齐 +63</b>(真 gap-fill)</td></tr>
|
||||
<tr><td>task</td><td class="c">18</td><td>todo</td><td class="c">20</td><td class="c s">S</td><td>持平</td></tr>
|
||||
<tr><td>drive</td><td class="c">26</td><td>drive</td><td class="c">25</td><td class="c s">S</td><td>持平</td></tr>
|
||||
<tr><td>apps</td><td class="c">63</td><td>devapp</td><td class="c">25</td><td class="c b">B</td><td><b>helper 无 devapp,25 全由 shortcut 补</b>,但仍少于 lark</td></tr>
|
||||
<tr><td>sheets</td><td class="c">84</td><td>sheet</td><td class="c">60</td><td class="c b">B</td><td>钉钉表格 MCP 较少;helper 已覆盖</td></tr>
|
||||
<tr><td>vc/okr/slides/markdown/whiteboard/note/event</td><td class="c">62</td><td>—</td><td class="c">0</td><td class="c b">B</td><td>钉钉无对应能力,<b>客观不可对齐</b></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="color:var(--muted);font-size:13px;margin:6px 0 2px">🟢 G 7 领域 · 🟡 S 3 领域 · 🔴 B(apps/sheets 少于 lark + 6 领域钉钉无能力)。base 的"持平"几乎全靠 shortcut gap-fill(helper 仅 16)。dws 独有:oa/attendance/report/ding/aisearch/live/devdoc。</div>
|
||||
|
||||
<h3>4.2 组合/智能层 GSB(关键发现)</h3>
|
||||
<div class="keyfind">
|
||||
<b>关键结论</b>:lark 有 ~104 个组合(≥2 次 API)shortcut,但 <b>lark 的组合性多源于飞书 REST API 太细粒度</b>(要先查 spreadsheetToken→sheetId→再操作);<b>钉钉 MCP 是粗粒度的——一个 tool = 一个完整操作</b>,所以 lark 的组合在钉钉这边<b>大量塌缩成 1:1</b>(已被封装层覆盖),或<b>根本没有对应 tool</b>。
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>lark 组合来源</th><th class="c">数量</th><th class="c">GSB</th><th>钉钉现实</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>sheets(先解析 sheetId)</td><td class="c">41</td><td class="c s">S</td><td>钉钉直接吃 token → 1:1 层已覆盖</td></tr>
|
||||
<tr><td>apps db-env/audit/log/trace</td><td class="c">17</td><td class="c b">B</td><td>钉钉无对应工具</td></tr>
|
||||
<tr><td>drive/doc/im(上传/媒体/搜索)</td><td class="c">23</td><td class="c s">S</td><td>多为钉钉 1:1 已覆盖</td></tr>
|
||||
<tr><td>calendar/contact/wiki/minutes/todo 编排</td><td class="c">~10</td><td class="c g">G</td><td>✅ 已建为真·智能 shortcut(+book/+lookup/+org/+wiki-new-doc/+reschedule…)</td></tr>
|
||||
<tr><td>okr/whiteboard/slides/vc</td><td class="c">11</td><td class="c b">B</td><td>钉钉无对应能力</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="color:var(--muted);font-size:13px">→ 钉钉真正需要「组合」的场景(按名解析+多工具编排+跨服务),dws 已覆盖并<b>额外做了 lark 没有的</b>(+today/+broadcast/+share-doc/+action-items 等)。<b>不盲目复刻 lark 的机械多步</b>(在钉钉会成冗余假组合)。</div>
|
||||
|
||||
<h2><span class="ico">⑤</span>dws 差异化优势</h2>
|
||||
<div class="card"><b>复用生产级 MCP 通道</b>(架构性)—— <code>CallMCP</code> 统一继承错误分类(auth/PAT/业务)、dry-run、<code>--format/--jq/--fields</code>;lark 每命令各自实现。</div>
|
||||
<div class="card"><b>协作能力覆盖更全</b> —— chat 95/mail 43/doc 34/minutes 25(dws tool 覆盖)是 lark 对应 2–4 倍。</div>
|
||||
<div class="card"><b>钉钉原生特有能力</b>(lark 完全没有)—— 审批/日志/考勤/DING/企业智能搜索,75 条差异化封装。</div>
|
||||
<div class="card"><b>真·智能编排(22 条)</b> —— 按名解析+消歧、失败回滚、跨服务;<code>resolveUser</code>/<code>CallMCPData</code> 让新智能 shortcut 越写越快。</div>
|
||||
<div class="card"><b>高频自动沉淀(P2 · lark 无此设计)</b>
|
||||
<div class="flow" style="margin-top:9px"><b>高频使用</b><span class="arw">→</span><b>埋点</b><span class="arw">→</span><b>suggest</b><span class="arw">→</span><b>add 写 YAML</b><span class="arw">→</span><b>运行时加载可用</b></div>
|
||||
</div>
|
||||
<div class="card"><b>工程质量</b> —— 假 Caller 拦截,366 条(含写/删)零副作用全量验证,可复跑回归。</div>
|
||||
|
||||
<div class="concl">
|
||||
<b>一句话结论</b>:dws 已把钉钉侧<b>能对齐的都对齐</b>(366 条 = 298 封装 + 68 智能 / 16 服务,1:1 层已去 213 条纯重复),即时协作显著优于 lark,拥有审批/考勤/DING 等原生差异化能力与「高频自动沉淀」独有闭环。lark 的组合优势多因飞书 API 细粒度、在钉钉粗粒度 MCP 下塌缩为 1:1(已覆盖),真正需编排的钉钉侧已建齐;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量验证。
|
||||
</div>
|
||||
|
||||
<footer>DWS Shortcut Report · 由持续精进 loop 维护 · 另见 <a href="shortcut-comparison.html">逐条三方对照 HTML</a> · <a href="shortcut-report.md">Markdown 版</a></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
# DWS Shortcut 能力整合与对齐报告
|
||||
|
||||
> 版本:截至本轮 loop | 对齐基准:larksuite/cli(lark-cli) | 执行底座:钉钉 MCP
|
||||
> 相关文档:[总规划](shortcut-plan.md) · [P2 自动沉淀设计](shortcut-p2-design.md) · [HTML 报告](shortcut-report.html) · [**逐条三方对照 HTML**](shortcut-comparison.html)(每个 shortcut:dws +命令 vs lark-cli vs 原生 MCP 组合)
|
||||
|
||||
---
|
||||
|
||||
## 1. Shortcut 整合了哪些能力
|
||||
|
||||
`dws` 现内建 **298 个 1:1 封装 shortcut** + **68 条 smart 智能编排命令**(§1.3)= **合计 366 条**,覆盖 **16 个钉钉服务**,以 `dws <service> +<command>` 形式提供。
|
||||
|
||||
> ⚠️ **重要修订(去冗余)**:1:1 层原为 511 条,**复盘发现 `internal/helpers/` 早已把大量 MCP tool 封装成 `dws <svc> <verb>` 产品命令**——其中 **213 条 1:1 shortcut 只是把已被 helper 封装过的同一个 tool 用 `+` 前缀又封了一遍、且无输出投影增量,属纯重复**,已删除。**保留的 298 条 = 233 条填 helper 空白(helper 从没封装的 tool)+ 65 条虽 tool 重复但加了干净投影**。这是对"建 1:1 层前没先摸清 helper 已封装什么"的纠偏(详见 §5 复盘)。aisearch/live/devdoc 三个服务的 shortcut 全属纯重复、已整包移除(其 `dws <svc>` 命令仍由 helper 层提供)。
|
||||
|
||||
### 1.1 能力清单(按服务,prune 后)
|
||||
|
||||
| 服务 | 1:1 shortcut 数 | 覆盖能力(摘要) |
|
||||
|------|:---:|------|
|
||||
| chat(群聊/消息) | 79 | 群管理、群成员、群身份角色、消息收发/撤回/转发/表情/卡片、会话置顶/免打扰、消息分组、机器人 |
|
||||
| aitable(多维表 base) | 77 | 数据表/字段/记录/视图/表单/仪表盘/图表/角色/协作全生命周期 |
|
||||
| attendance(考勤)★ | 33 | 打卡记录、审批、排班、班次、考勤组、统计报表、请假 |
|
||||
| devapp(开放平台应用 apps) | 30 | 应用增删改查、成员、权限、版本发布、事件订阅、扩展机器人/H5 配置 |
|
||||
| doc(文档) | 16 | 文档/文件夹、正文块读写、权限、附件、节点 |
|
||||
| contact(通讯录) | 9 | 用户/部门搜索与详情、角色、花名册 |
|
||||
| drive(钉盘) | 8 | 文件/文件夹管理、下载、复制移动、权限、最近访问 |
|
||||
| calendar(日历) | 8 | 日程、参与人、会议室、忙闲、ACL、日历本 |
|
||||
| minutes(AI 听记) | 7 | 妙记详情/逐字稿、录音控制、说话人 |
|
||||
| oa(审批)★ | 6 | 审批实例、单据处理、模板、流程 |
|
||||
| mail(邮箱) | 6 | 邮件搜索/线程、标签、联系人、收信规则(投影类保留) |
|
||||
| wiki(知识库) | 5 | 知识空间、节点、成员 |
|
||||
| todo(待办 task) | 5 | 待办、子任务、执行人/参与人、附件 |
|
||||
| ding(DING)★ | 5 | 机器人/个人 DING 发送、撤回、接收状态 |
|
||||
| sheet(钉钉表格) | 2 | 区域读写(投影类保留) |
|
||||
| report(日志)★ | 2 | 日志收件箱/发件箱 |
|
||||
| **合计** | **298** | **16 个服务** |
|
||||
|
||||
★ = 钉钉特有服务,lark-cli 无对应(详见 §3 GSB)。**注**:多数服务的 `shortcut 数` 已远小于该服务的 MCP tool 总数——因为 tool 的基础封装由 helper 层的 `dws <svc> <verb>` 命令承担,1:1 shortcut 只保留 helper 没覆盖的、或加了投影的。
|
||||
|
||||
### 1.2 每个 shortcut 统一具备的能力(框架注入)
|
||||
|
||||
不是简单命令别名,而是叠加在裸 MCP 之上的**精选薄封装**,统一获得:
|
||||
|
||||
- **声明式定义**:`Shortcut{Service, Command, Product, Risk, Flags, Execute}`,一处声明、框架编译成 cobra 命令。
|
||||
- **自然语言 Intent**:每条 shortcut 均带一段自然语言描述(做什么/何时用/关键输入产出,写删类点明副作用),面向用户与 AI agent 的意图匹配;`--help` 展示为长描述,`dws shortcut list` 输出 `intent` 字段。全部 366 条覆盖(`TestAllHaveIntent` 强制校验)。
|
||||
- **参数收敛**:把裸 `dws mcp <svc> <tool> --json '{...}'` 的手拼 JSON,收敛成命名 flag(`--query`/`--group`…)。
|
||||
- **内建校验**:required / enum 声明式校验,结构化错误提示。
|
||||
- **风险确认**:read / write / high-risk-write 分级,写/删操作 `--yes` 前二次确认。
|
||||
- **复用生产级 MCP 通道**:错误分类(auth/PAT/业务)、`--dry-run` 预览、`--format`/`--jq`/`--fields` 输出,全部免费继承(详见 §4)。
|
||||
|
||||
### 1.3 两个层次:1:1 封装层 vs 真·多步/智能层(重要澄清)
|
||||
|
||||
诚实区分——上面 298 条**绝大多数是 1 shortcut ≡ 1 个 MCP tool 的 1:1 封装**,本质是「给 MCP 套命名 flag + 校验 + Intent 的友好外壳」,价值在 DX 与 agent 可发现性,**不是新能力**。
|
||||
|
||||
真正的「shortcut 作为新能力」是 `internal/shortcut/smart/` 下的**多步/智能** shortcut——照 larksuite/cli 的实现范式(`CallAPITyped` 链式多步、按名解析 ID、Validate、DryRun 计划、失败回滚)落地。框架为此新增 `RuntimeContext.CallMCPData(product, tool, params)`(对应 lark 的 `CallAPITyped`:调用并返回 data 供下一步,跨服务)。
|
||||
|
||||
已落地的真·智能 shortcut(`internal/shortcut/smart/`,共 68 条,下表为代表性节选):
|
||||
|
||||
| shortcut | 多步/智能逻辑 | 验证 |
|
||||
|----------|--------------|------|
|
||||
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
|
||||
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
|
||||
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
|
||||
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
|
||||
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
|
||||
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
|
||||
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
|
||||
| `calendar +suggest-time --with <姓名CSV>` | 解析多人→推荐可开会时间 | ✅ 编译/挂载 |
|
||||
| `calendar +invite --event <id> --with <姓名CSV>` | 解析多人→加入已有日程 | ✅ 编译/挂载 |
|
||||
| `doc +share-doc --to <姓名> --url` | 解析人→把文档链接私信 TA | ✅ 编译/挂载 |
|
||||
| `calendar +today` | 算出今天时间范围→列我今天的日程 | ✅ **真机端到端**(返回真实日程+参会人) |
|
||||
| `calendar +next-event` | 近 7 天日程→按时间取最近一个 | ✅ 编译/挂载 |
|
||||
| `contact +team --name <姓名>` | 解析人→取部门→列部门直接成员 | ✅ 编译/挂载 |
|
||||
| `todo +remind --task --at` | 给自己建带截止/提醒时间的待办 | ✅ 编译/挂载 |
|
||||
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
|
||||
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
|
||||
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
|
||||
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
|
||||
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
|
||||
|
||||
| `oa +approve-by --keyword` ★ | 列待审批→匹配→取 taskId→通过(钉钉原生,lark 无) | ✅ 编译/挂载 |
|
||||
| `attendance +my-attendance` ★ | 当前用户→算今天→查我打卡(路由 attendance-wukong server) | ✅ 编译/挂载 |
|
||||
| `todo +overdue` | 列我待办→本地过滤过期→投影输出 | ✅ **真机端到端** |
|
||||
| `report +report-latest` ★ | 列我日志→取最新→取详情 | ✅ 编译/挂载 |
|
||||
| `aitable +find-record --base --table` | 表内按关键词查记录 | ✅ 编译/挂载 |
|
||||
|
||||
另有 gap-buildable 补齐(批6):`chat +my-groups`(列群+类型过滤+投影)、`calendar +find-room`(时段找可用会议室)、`minutes +minutes-search`(关键词搜妙记)、`mail +search-mail`(搜邮件+自动解析绑定邮箱)、`drive +find-file`(搜钉盘文件+投影)。
|
||||
|
||||
批7-8 续补(10 条):`chat +at-me`(近期@我)、`calendar +cancel-event`(查→删,高危二次确认)、`todo +assign-multi`(多人指派)、`contact +dept-members`(搜部门→列成员)、`minutes +transcript`(最新妙记逐字稿)、`calendar +week`(本周日程)、`contact +by-mobile`(手机号→资料)、`todo +created-todos`(我创建的)、`chat +unread-chats`(未读会话)、`mail +unread-mail`(未读邮件)。
|
||||
|
||||
批9 续补(3 条·手工,对齐 gap-buildable):`minutes +detail`(单命令聚合一条听记的 basic/summary/keywords/transcript/todos,partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合,补齐一次一组的 1:1 `+word-replace`)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片(≤20/批)+跨 `aitable-helper` server fanout+合并,补齐单批 20 条上限)。
|
||||
|
||||
批10 续补(3 条·多 agent 并行,对齐 gap-buildable):`chat +thread-replies`(拉某条话题消息的全部回复 list_topic_replies + sender/text/time 投影)、`todo +related-tasks`(creator+executor+participant 三角色并集「与我相关的待办」+ taskId 去重 + 投影)、`doc +find-doc`(按关键词搜云文档 search_documents + title/url/type/token 投影)。
|
||||
|
||||
批11 续补(3 条·多 agent 并行):`aitable +resolve-base`(按名搜 Base 解析 baseId,0/1/多候选消歧 search_bases)、`chat +chat-messages`(群/单聊会话消息列表,list_conversation_message_v2 / list_individual_chat_message 互斥+投影)、`mail +find-mail-user`(按名/邮箱搜企业邮箱联系人 search_mail_users + 投影)。
|
||||
|
||||
批12 续补(3 条·多 agent 并行,dws 原生 resolver 层,按名解析 ID):`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内名→tableId,本地匹配)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,含数值 ID 兼容)。均 0/1/多候选消歧,对标 `resolveUser` 的各资源版。
|
||||
|
||||
批28 续补(3 条·净新增便利读,dws 原生):`oa +pending`(**只读**列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(今天到期待办,planFinishDate 服务端过滤,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。均只读、真机验证(+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper)。
|
||||
|
||||
批29 续补(3 条·净新增便利读,dws 原生):`oa +done-approvals`(我已处理的审批历史 get_done_tasks;真机抓到并修复 pageSize=0 → 默认 20 的后端报错 bug)、`mail +recent-mail`(近期收件箱会话 list_mailbox_threads + 解析绑定邮箱/收件箱)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance 自身解析)。真机:+this-month 返回有效空、+done-approvals 修后走空路径、+recent-mail 正确报未绑定邮箱。
|
||||
|
||||
批30 续补(1 条·净新增便利读):`contact +me`(当前用户 get_current_user_profile + 投影 {name,userId,mobile,dept,org,email},agent 的「我是谁」;区别于 1:1 +get-self 吐冗长 raw,真机验证 董鑫阳/202397/模型算法)。
|
||||
|
||||
批31 续补(1 条·净新增便利读):`calendar +my-free`(我自己的忙闲,自动解析当前 userId,默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名,真机验证返回今日忙碌时段)。
|
||||
|
||||
批32 续补(1 条·净新增 dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两重叠检测,默认今天/--in-days;真机验证抓到今日 2 处真实冲突)。这类纯 MCP-tool 的本地编排是复杂写死胡同之外仍有价值的方向。
|
||||
|
||||
批33 续补(1 条·净新增 dws 原生编排,+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段"什么时候能安排会",list_calendar_events + 合并忙碌区间 + 工作窗口内求补集,默认今天 09:00-18:00/--from/--to/--in-days;真机验证今日 4 段空档)。
|
||||
|
||||
共 **68 条真·智能 shortcut**(多批多 agent 工作流并行生成 + 手工续补)。★=钉钉原生编排,lark 完全没有。
|
||||
|
||||
**真机验证(登录态抽样,返回真实数据)**:`calendar +today/+week`(真实日程+投影)、`contact +org`(3 步→部门详情)、`contact +lookup/+free`、`todo +overdue`、`attendance +my-attendance` 等端到端可用。
|
||||
|
||||
### 深度对齐矩阵(逐条分析 lark 361 条 shortcut)
|
||||
|
||||
见 [`shortcut-lark-alignment.md`](shortcut-lark-alignment.md)——12 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉:
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 (40%) | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 (35%) | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42 (12%)** | 钉钉有工具、值得补成智能 shortcut(建设目标) |
|
||||
| covered-smart | 48 (13%) | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
**框架系统性能力已对齐 lark**:`resolveUser`(名→ID)· `CallMCPData`(多步取数)· `rt.Output`(输出投影)· `rt.MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll`(跨字段校验)。
|
||||
|
||||
### 保真度升级(对齐 lark 96% 的输出投影)
|
||||
|
||||
lark 96% 的 shortcut 都做**输出投影**(把原始 API 返回精简为干净字段列表)。已给 **~60 条列表/读类封装**升级到此保真度——从 `rt.CallMCP`(打印原始 MCP 返回)改为 `rt.CallMCPData` + 防御式投影 + `rt.Output`(自动吃 `--format/--jq/--fields`):
|
||||
|
||||
`contact +search-user/+search-mobile/+list-roles/+list-sub-depts` · `todo +get-my-tasks/+list-sub` · `calendar +book-list/+attendee-list` · `drive +list` · `wiki +node-list` · `chat +conversation-list/+category-list/+messages-list-unread-conversations/+messages-list-pin` · `doc +search/+list` · `mail +tag-list/+contact-list` · `aitable +base-list/+base-search` · `oa …`
|
||||
|
||||
示例:`contact +search-user` 由原始 MCP 返回 → 干净 `{count, users:[{name,userId,flowerName,openDingTalkId,title}]}`(真机验证)。这是把封装层往 lark 高保真水平系统性拉升的开始。另有 `mail +to`(按名发邮件)被 agent **正确 skip**——钉钉 contact 无 email 字段、mail `send_email` 需发件人邮箱 `from` 无法解析,宁缺勿错不编造。关键复用:「按名解析人」抽成共享 helper `resolveUser`(对标 lark `ResolveOpenIDsTyped`,带 0/多人消歧,不瞎猜);多步靠 `CallMCPData`(对标 lark `CallAPITyped`)。其中 5 条由多 agent 工作流并行生成——各自以 helper 为 ground truth 研究参数、`send-to-group` 的 agent 还主动纠正了「按群名搜群」的正确工具(`search_groups` 而非按成员昵称的 `search_common_groups`)。
|
||||
|
||||
> 定位:1:1 层是「MCP 友好外壳」,smart 层才是「真 shortcut」。二者不混淆。
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试验证报告
|
||||
|
||||
**验证理念**:写/删命令不能真跑(会发消息、解散群、删数据),故用**假 Caller 拦截**——让每条命令(含写/删)真实走完「解析→校验→确认→组装 MCP 调用」全流程,捕获组装出的 `(product, tool, params)`,只是不真发网络。以此对**全部 366 条**做零副作用验证。
|
||||
|
||||
### 2.1 结果总览(全绿)
|
||||
|
||||
| 验证项 | 范围 | 结果 |
|
||||
|------|------|------|
|
||||
| `go build ./...` | 全仓 | ✅ 通过 |
|
||||
| `gofmt -l` / `go vet` | shortcut 全包 | ✅ 0 未格式化 / 0 告警 |
|
||||
| 框架单元测试(5) | 类型/挂载/校验/分组 | ✅ 全通过 |
|
||||
| **TestAllShortcutsAssemble** | **全部 366 条** | ✅ 322 组装真实 MCP · 44 自校验拦截 · **0 失败 · 0 panic** |
|
||||
| **TestAllToolLiteralsAreReal** | 全部 tool 字面量(逐条) | ✅ **0 编造**(tool 名逐一比对 helper ground truth) |
|
||||
| TestNoDuplicateCommands | 全部 366 条 | ✅ 无重复、命名规范(均 `+` 前缀) |
|
||||
| **TestAllHaveIntent** | 全部 366 条 | ✅ 每条均有自然语言 Intent 描述(无一遗漏) |
|
||||
| usage 包单测(4) | 埋点/脱敏/聚合/开关 | ✅ 全通过 |
|
||||
| app 包全量回归 | `internal/app` | ✅ 72.2s 通过(接线未破坏任何现有命令) |
|
||||
| 只读命令真机验证 | ~25 条(登录态,见 §2.4) | ✅ `contact +me`/`+org`/`+lookup`、`calendar +today/+week/+free/+conflicts/+free-slots`、`doc +find-doc`、`aitable +resolve-base/+resolve-table`、`drive +find-file`、`oa +my-initiated` 等端到端返回真实数据、投影核对 |
|
||||
|
||||
### 2.2 关键指标解读
|
||||
|
||||
- **322 「组装真实 MCP」**:喂合成参数后成功组装出 MCP 调用,且 tool 名经 helper ground truth 核验真实、非编造。
|
||||
- **44 「自校验拦截」**:这些命令有结构化/JSON/互斥输入(如多维表建记录需 JSON、DING 三选一接收人),dummy 值被其**自身校验正确拒绝**——证明校验链路健全。其 tool 名由静态测试 `TestAllToolLiteralsAreReal` 单独覆盖,无遗漏。
|
||||
- **0 编造 / 0 panic / 0 失败**:无幻觉工具名,无运行时崩溃,无死命令。
|
||||
|
||||
> ⚠️ **验证强度分层(诚实口径,勿把"全绿"读成"真机全对")**:`TestAllShortcutsAssemble` 的"0 失败"只证明**能正确组装 MCP 调用、零副作用**——它用**合成响应**,**验证不了防御式投影是否匹配真实响应结构**(真机验证正是靠这个抓到过 deptList 容器、pageSize=0 等 assemble 盖不住的 bug,见 §2.4)。按真机验证强度分三层:**(A) 真机正向验证** ~25 条只读/解析类(返回真实数据、投影核对);**(B) 仅 assemble + 复用已验证 helper**(如 +due-today/+this-month 等,逻辑同构于已验证命令,但该条本身未在真机跑出正样本);**(C) 未对真实后端跑过**——17 条写类 smart(不宜真跑,会发消息/建数据)、6 条 minutes smart(该 org 未开 CLI 数据访问)、mail +recent-mail(无绑定邮箱)。(C) 类**很可能仍有 assemble 盖不住的投影/参数 bug**,不应因"全绿"就当作"真机可用"。
|
||||
|
||||
### 2.3 防幻觉机制(工作流生成时)
|
||||
|
||||
生成阶段每个服务由独立 agent 负责,硬性规则:tool 名与参数 key **只能逐字取自 dws helper 的真实调用点**,无法确定参数的 tool 主动跳过并记录原因(如嵌套对象、时间戳转换、本地文件分片上传)。测试阶段再用 ground truth 二次核验,双重保险。
|
||||
|
||||
### 2.4 真机验证战役(登录态打真实钉钉后端)
|
||||
|
||||
assemble 测试用**合成响应**,能验证「调用是否组装正确」,但验证不了「防御式投影解析是否匹配真实响应结构」。为此做了 9 批真机验证(登录态 corp「钉钉」,token 有效期内),把只读/解析类 smart shortcut 打真实后端、逐条核对投影输出。
|
||||
|
||||
**正向验证 20+ 条**(返回真实数据、投影正确):`doc +find-doc`、`aitable +resolve-base`/`+resolve-table`/`+list-tables`/`+base-list`/`+find-record`、`mail +find-mail-user`、`chat +my-groups`/`+group-members`/`+at-me`、`contact +org`/`+lookup`、`calendar +today`/`+week`/`+next-event`/`+free`/`+suggest-time`、`todo +overdue`/`+related-tasks`、`attendance +my-attendance`、`report +report-latest`、`oa +my-initiated`、`drive +find-file`、`wiki +resolve-space` 等。
|
||||
|
||||
**真机抓到并修复 8 个真实问题**(assemble 测试抓不到,只有真机能抓):
|
||||
|
||||
| shortcut | 真机发现的问题 | 修复 |
|
||||
|---|---|---|
|
||||
| `contact +resolve-dept` | 对任何真实部门名都「未找到」——真实响应容器 key 是 `deptList`(防御探测清单漏了),deptName 带 `<red>` 高亮、deptId 是数值 | 加 `deptList` 探测 + `stripHighlightTags` + 数值 coerce |
|
||||
| `contact +dept-members` | 消歧消息泄漏 `<red>` 标记 | 复用 `stripHighlightTags` |
|
||||
| `chat +unread-chats` | 每行吐 `unread: null`(底层不返回每会话未读数) | 仅当有值才带该字段 |
|
||||
| `chat +at-me` | 直吐原始两层嵌套、未拍平 | 新增 `atMeFlattenGroups`,拍平 43 条为 `{conversation,sender,text,time}` |
|
||||
| `chat +group-members` | 终结步 raw `CallMCP` 吐冗长 raw(含 avatar 媒体 ID + errorCode 噪音) | 升级 `CallMCPData`+投影 `{name,nick,role,openDingtalkId}` |
|
||||
| `calendar +free` | 吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套 | 投影为 `{who,userId,free,busy:[{start,end}]}` |
|
||||
| `calendar +today` | 吐 17 字段冗长事件(含完整 attendees 数组),与 `+week` 不一致 | 投影为 `{title,start,end,location,eventId}`,对齐 `+week` |
|
||||
| `calendar +suggest-time` | `timeConflictAttendees:[null]` 噪音 + result 包裹 | 拍平 + 丢 null 冲突 → `{suggestions:[{start,end}]}` |
|
||||
|
||||
**后端受限、无法真机正向验证的(非代码问题)**:`chat +chat-messages`/`+search-msg`(读会话消息需更高 PAT 权限 / org 未开 CLI 数据访问)、`minutes +*`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)、`contact +team`(列部门成员 medium-risk 权限墙)。这些命令的**组装链路**经 assemble 测试验证正确,仅无法在本环境跑通后端。
|
||||
|
||||
**一个已澄清的非 bug**:`resolveUser` 对组织内成员(如董鑫阳→userId 202397)真机端到端正常;对外部/资料受限联系人 `search_contact_by_key_word` 只返回 openDingTalkId(name/userId 全 null),此时正确报「没找到」而非瞎猜——钉钉数据模型现实,非代码缺陷。
|
||||
|
||||
> **结论**:真机验证证明「防御式多候选 key 投影」在真实响应上整体成立,并纠正了 8 处「合成测试盖不住」的解析/投影偏差。这是把可用性从「组装正确」提升到「真机输出正确」的关键一环。
|
||||
|
||||
---
|
||||
|
||||
## 3. 效果评估 GSB(vs lark-cli)
|
||||
|
||||
以 lark-cli 各服务领域为基准,评估 dws shortcut 的相对表现。**G**ood=优于/更全,**S**ame=持平,**B**ad=弱于/缺失。
|
||||
|
||||
> ⚠️ 重要前提:两边是**不同 API**(飞书 vs 钉钉),数量不能机械 1:1;覆盖度受钉钉实际能力约束。
|
||||
>
|
||||
> **口径(prune 后重做)**:不再用 shortcut 数(会因去冗余失真),改用**「dws 该服务实际暴露的 distinct MCP tool 数」= helper 命令 ∪ shortcut 覆盖的 tool 合集**——这才代表真实能力,与 helper/shortcut 怎么分层无关。对比 lark 的 shortcut 数(不同 API,只作量级参考)。
|
||||
|
||||
| lark 服务 | lark 数 | dws 对应 | dws tool 覆盖 | (其中 shortcut 补) | GSB | 说明 |
|
||||
|-----------|:---:|---------|:---:|:---:|:---:|------|
|
||||
| im | 21 | chat | **95** | +3 | 🟢 G | 群/消息/机器人能力更全 |
|
||||
| mail | 21 | mail | **43** | +0 | 🟢 G | 覆盖更广(几乎全由 helper 提供,shortcut 曾重复、已 prune) |
|
||||
| doc | 14 | doc | **34** | +4 | 🟢 G | 块级读写更细 |
|
||||
| minutes | 14 | minutes | **25** | +0 | 🟢 G | 录音控制/说话人更全 |
|
||||
| calendar | 12 | calendar | **24** | +5 | 🟢 G | 会议室/ACL/忙闲;shortcut 另补排期智能 |
|
||||
| contact | 2 | contact | **15** | +0 | 🟢 G | 部门/角色/花名册更全 |
|
||||
| wiki | 12 | wiki | **16** | +0 | 🟢 G | 略优 |
|
||||
| base | 87 | aitable | **79** | **+63** | 🟡 S | 基本持平;**helper 仅 16,shortcut 层补齐了绝大部分**(真·gap-fill) |
|
||||
| task | 18 | todo | **20** | +1 | 🟡 S | 持平 |
|
||||
| drive | 26 | drive | **25** | +4 | 🟡 S | 基本持平 |
|
||||
| apps | 63 | devapp | **25** | **+25** | 🔴 B | **helper 无 devapp 命令、25 个全由 shortcut 提供**(纯 gap-fill),但仍少于 lark |
|
||||
| sheets | 84 | sheet | **60** | +1 | 🔴 B | 钉钉表格 MCP 较少;helper 已覆盖,shortcut 曾重复、已 prune |
|
||||
| vc | 18 | — | 0 | — | 🔴 B | 钉钉 conference 无干净 MCP tool |
|
||||
| okr | 13 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
|
||||
| slides / markdown / whiteboard / note / event | 17 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
|
||||
|
||||
**dws 独有(lark 无对应服务)**:oa 审批(~20 tool) · attendance 考勤(~38) · report 日志(~7) · ding(~8) · aisearch/live/devdoc(helper 层提供)——钉钉工作流核心,构成差异化。
|
||||
|
||||
### GSB 汇总
|
||||
|
||||
- 🟢 **G(7 领域)**:im/mail/doc/minutes/calendar/contact/wiki——dws tool 覆盖更全。
|
||||
- 🟡 **S(3 领域)**:base/task/drive 量级持平(base 的持平**几乎全靠 shortcut 层 gap-fill**,helper 只有 16)。
|
||||
- 🔴 **B(受限 2 + 不可对齐 6)**:apps/sheets 少于 lark(sheets 受钉钉 API 限,apps 全由 shortcut 补但仍少);vc/okr/slides/markdown/whiteboard/note/event 客观不可对齐(非遗漏)。
|
||||
- **注**:这张表也印证了 1:1 层的真实价值分布——**base/apps 靠 shortcut 补了大量 helper 没有的 tool(gap-fill),而 mail/sheets 的 shortcut 基本是重复 helper(已 prune)**。
|
||||
|
||||
---
|
||||
|
||||
## 4. dws 差异化于 lark 的优势
|
||||
|
||||
### 4.1 执行底座:复用生产级 MCP 通道(架构性优势)
|
||||
lark-cli 每个 shortcut 直连飞书 SDK,错误处理/输出各自实现。dws shortcut 的 `CallMCP` **委托统一的 MCP 调用路径**,一步继承:
|
||||
- **错误分类**:auth 过期 / 未登录 / PAT / 业务错误,自动给出可执行提示;
|
||||
- **`--dry-run` 预览**:不发网络,输出将执行的 tool + 参数;
|
||||
- **`--format`/`--jq`/`--fields`**:机器可解析输出,Agent 友好。
|
||||
|
||||
lark 需在每个命令重复实现这些;dws 由框架统一注入,一致性与维护成本双赢。
|
||||
|
||||
### 4.2 覆盖更全的高频协作能力
|
||||
按 **dws tool 覆盖**(helper∪shortcut,§3 口径):chat 95 / mail 43 / doc 34 / minutes 25 / calendar 24 等即时协作场景,多为 lark 对应服务的 2–4 倍。
|
||||
|
||||
### 4.3 钉钉原生特有能力(lark 完全没有)
|
||||
审批 oa、日志 report、考勤 attendance、DING、企业智能搜索 aisearch —— 这些是钉钉工作流的核心,构成差异化护城河。
|
||||
|
||||
### 4.4 高频自动沉淀(P2,lark 无此设计)
|
||||
基于用户高频使用**主动把常用操作沉淀为自定义 shortcut**(`~/.dws/shortcuts/*.yaml` 运行时加载),让 CLI 越用越顺手。埋点**默认关(opt-in,`DWS_USAGE_TRACKING=1` 开启)**+开启后首跑告知,隐私优先(记形状不记值)。详见 [P2 设计](shortcut-p2-design.md)。**lark-cli 无任何等价能力。**
|
||||
|
||||
### 4.5 AI Agent 友好
|
||||
`--yes` 跳过确认、结构化错误、`--dry-run` 预览、`--print-schema`(规划中)——为 Agent 自动化调用而设计。
|
||||
|
||||
### 4.6 工程质量:全量自动化测试
|
||||
假 Caller 拦截,对全部 366 条(含写/删)做零副作用验证 + tool 名 ground truth 核验,可复跑、可回归。
|
||||
|
||||
---
|
||||
|
||||
## 5. 复盘与后续(loop 持续项)
|
||||
|
||||
### 5.0 关键复盘:1:1 层去冗余(511 → 298)
|
||||
**问题**:建 1:1 shortcut 层之前,**没有先摸清 `internal/helpers/` 已经把哪些 MCP tool 封装成了 `dws <svc> <verb>` 产品命令**。结果对 **213 个 tool 重复封装**——同一个 tool,helper 有 `dws contact user search`、我又造了 `dws contact +search-user`,且这批无投影增量,纯重复。
|
||||
**纠偏**:按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 213 条,1:1 层 511 → **298**(保留 233 填空白 + 65 有投影),总数 579 → **366**,服务 19 → **16**(aisearch/live/devdoc 整包移除)。全绿、真机复验保留命令仍可用。
|
||||
**教训**:**做封装层前先审已有封装**。对齐 lark「每一条 shortcut」时,应先问「dws 这边是不是已经有等价命令了」,而不是无脑对齐。这是本项目最大的方法论盲点。
|
||||
|
||||
1. ~~补 apps(↔devapp)~~ ✅ 已完成:新增 30 个 devapp shortcut。
|
||||
2. **P2 落地**(差异化能力,lark 无):
|
||||
- ✅ **P2-1 usage 埋点**:装饰 MCP 调用唯一必经点记录 `~/.dws/usage.jsonl`(**记形状不记值**,敏感/自由文本字段脱敏;**默认关/opt-in**,`DWS_USAGE_TRACKING=1` 开启、开启后首跑告知)。端到端验证通过。
|
||||
- ✅ **stats/list**:`dws shortcut list [--service]`、`dws shortcut stats [--top N] [--purge]`(按 `(product,tool,arg_keys)` 聚合、识别固定值 fixed_args)。
|
||||
- ✅ **P2-2 suggest**:`dws shortcut suggest [--min N]` 把高频分组转成「建议沉淀的 +command」候选(含固定/可变参数拆分)。
|
||||
- ✅ **P2-3 YAML 自定义 shortcut 沉淀闭环**:`dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 下次运行 `userdef.Load()` 编译成 Shortcut 注册(复用同一 runner;`${flag}` 绑定+常量;与内建冲突自动跳过)。**端到端验证通过**:add→重载→`dws <svc> +<cmd>` 可用,dry-run 组装正确。
|
||||
- ⏳ **P2-4 nudge**:命中高频候选时主动提示(TTY、频控、可永久关闭)。
|
||||
|
||||
> 至此,**「高频使用 → 建议 → 一键沉淀 → 自定义 shortcut 运行时生效」完整闭环已打通**——这是 lark-cli 完全没有的差异化能力。
|
||||
3. **深化 sheets**:随钉钉表格 MCP 能力增强补齐。
|
||||
4. **实机全读回归**:登录态下对全部只读 shortcut 做真实调用回归(需有效 token + 真实资源 ID)。
|
||||
5. **不可对齐项归档**:okr/slides/whiteboard/note/vc/event 明确标注为钉钉无能力,避免误解为遗漏。
|
||||
|
||||
---
|
||||
|
||||
## 附:一句话结论
|
||||
|
||||
> dws 已把钉钉侧**能对齐的主要服务全部对齐**(16 服务 / **366 shortcut** = 298 封装 + 68 智能编排),在即时协作能力上显著优于 lark,并拥有审批/考勤/日志/DING 等钉钉原生差异化能力与「高频自动沉淀」独有设计;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量自动化验证。
|
||||
@@ -3,15 +3,17 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -35,7 +37,6 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -86,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newAuditCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "操作审计日志管理",
|
||||
Long: "查看、导出和校验本地操作审计日志。",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuditTailCommand(),
|
||||
newAuditExportCommand(),
|
||||
newAuditVerifyCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditTailCommand() *cobra.Command {
|
||||
var n int
|
||||
cmd := &cobra.Command{
|
||||
Use: "tail",
|
||||
Short: "查看最近的审计记录",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if n < 1 {
|
||||
return fmt.Errorf("--lines 必须为正整数,收到 %d", n)
|
||||
}
|
||||
dir := auditDir()
|
||||
file, err := audit.LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无审计记录: %w", err)
|
||||
}
|
||||
lines, err := tailFile(file, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range lines {
|
||||
fmt.Println(line)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditExportCommand() *cobra.Command {
|
||||
var since, until, format string
|
||||
cmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出审计日志",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir := auditDir()
|
||||
|
||||
sinceDate := strings.ReplaceAll(since, "-", "")
|
||||
untilDate := strings.ReplaceAll(until, "-", "")
|
||||
|
||||
files, err := audit.AuditFilesInRange(dir, sinceDate, untilDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找审计文件失败: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("指定范围内无审计文件")
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "jsonl":
|
||||
return exportJSONL(files)
|
||||
case "csv":
|
||||
return exportCSV(files)
|
||||
default:
|
||||
return fmt.Errorf("不支持的格式: %s(可选 jsonl, csv)", format)
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuditVerifyCommand() *cobra.Command {
|
||||
var file string
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "校验审计日志哈希链完整性",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target := file
|
||||
if target == "" {
|
||||
dir := auditDir()
|
||||
var err error
|
||||
target, err = audit.LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无审计文件: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
valid, brokenAt, err := audit.VerifyFile(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("校验失败: %w", err)
|
||||
}
|
||||
if valid {
|
||||
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
|
||||
} else {
|
||||
fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt)
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func auditDir() string {
|
||||
if dir := os.Getenv(audit.EnvAuditDir); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return filepath.Join(defaultConfigDir(), "audit")
|
||||
}
|
||||
|
||||
func tailFile(path string, n int) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func exportJSONL(files []string) error {
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
fmt.Println(scanner.Text())
|
||||
}
|
||||
f.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportCSV(files []string) error {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
|
||||
header := []string{"timestamp", "execution_id", "user_id", "corp_id", "product", "command", "result", "duration_ms", "error_category"}
|
||||
if err := w.Write(header); err != nil {
|
||||
return fmt.Errorf("写入 CSV 表头失败: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
var evt audit.Event
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("解析审计记录失败 %s:%d: %w", file, lineNum, err)
|
||||
}
|
||||
row := []string{
|
||||
evt.Timestamp.Format(time.RFC3339),
|
||||
evt.ExecutionID,
|
||||
evt.Actor.UserID,
|
||||
evt.Actor.CorpID,
|
||||
evt.Product,
|
||||
evt.Command,
|
||||
evt.Result,
|
||||
strconv.FormatInt(evt.DurationMs, 10),
|
||||
evt.ErrCategory,
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("写入 CSV 记录失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
if err := w.Error(); err != nil {
|
||||
return fmt.Errorf("刷新 CSV 输出失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuditTailRejectsNonPositiveLines(t *testing.T) {
|
||||
for _, n := range []string{"0", "-1"} {
|
||||
cmd := newAuditTailCommand()
|
||||
cmd.SetArgs([]string{"--lines", n})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("--lines %s: expected error, got nil", n)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "正整数") {
|
||||
t.Fatalf("--lines %s: unexpected error: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailFileReturnsLastN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
if err := os.WriteFile(path, []byte("a\nb\nc\nd\ne\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := tailFile(path, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 2 || lines[0] != "d" || lines[1] != "e" {
|
||||
t.Fatalf("got %v, want [d e]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportCSVWritesHeaderAndRows(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
rec := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1","corp_id":"c1"},"product":"calendar","command":"event_list","result":"success","duration_ms":12,"hash":"h","prev_hash":""}`
|
||||
if err := os.WriteFile(path, []byte(rec+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
exportErr := exportCSV([]string{path})
|
||||
w.Close()
|
||||
os.Stdout = stdout
|
||||
|
||||
if exportErr != nil {
|
||||
t.Fatalf("exportCSV error: %v", exportErr)
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Read(buf)
|
||||
out := string(buf[:n])
|
||||
if !strings.Contains(out, "timestamp,execution_id") {
|
||||
t.Fatalf("missing CSV header, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "e1") || !strings.Contains(out, "event_list") {
|
||||
t.Fatalf("missing CSV row data, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportCSVFailsOnMalformedJSON guards the reviewer's V9 finding: a corrupt
|
||||
// JSONL line must surface an error with file/line evidence instead of being
|
||||
// silently skipped while the command exits 0.
|
||||
func TestExportCSVFailsOnMalformedJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit-20260101.jsonl")
|
||||
good := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1"},"product":"calendar","command":"event_list","result":"success","duration_ms":1,"hash":"h","prev_hash":""}`
|
||||
if err := os.WriteFile(path, []byte(good+"\nnot-json\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
exportErr := exportCSV([]string{path})
|
||||
w.Close()
|
||||
os.Stdout = stdout
|
||||
// Drain the pipe so the writer never blocks.
|
||||
buf := make([]byte, 4096)
|
||||
_, _ = r.Read(buf)
|
||||
|
||||
if exportErr == nil {
|
||||
t.Fatal("expected error on malformed JSONL, got nil")
|
||||
}
|
||||
if !strings.Contains(exportErr.Error(), "解析审计记录失败") {
|
||||
t.Fatalf("error missing parse context: %v", exportErr)
|
||||
}
|
||||
if !strings.Contains(exportErr.Error(), ":2") {
|
||||
t.Fatalf("error missing line evidence: %v", exportErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
auditSinkOnce sync.Once
|
||||
auditCloseOnce sync.Once
|
||||
sharedAuditSink audit.Sink
|
||||
|
||||
auditIDMu sync.Mutex
|
||||
cachedActor audit.Actor
|
||||
cachedAgentID string
|
||||
cachedProfile string
|
||||
identityLoaded bool
|
||||
|
||||
// loadTokenForProfile is the profile-scoped token loader. It is a package
|
||||
// variable so profile-switch Actor attribution can be tested deterministically
|
||||
// without touching the OS keychain.
|
||||
loadTokenForProfile = auth.LoadTokenDataForProfile
|
||||
)
|
||||
|
||||
// setupAuditSink builds the process-wide audit sink once and caches it so the
|
||||
// runner and the shutdown hook share a single writer/forwarder instance.
|
||||
func setupAuditSink() audit.Sink {
|
||||
auditSinkOnce.Do(func() {
|
||||
sink, err := audit.BuildSink(defaultConfigDir(), auditReport)
|
||||
if err != nil {
|
||||
auditReport("initialization failed, audit disabled for this session: %v", err)
|
||||
sharedAuditSink = audit.NopSink{}
|
||||
return
|
||||
}
|
||||
sharedAuditSink = sink
|
||||
})
|
||||
return sharedAuditSink
|
||||
}
|
||||
|
||||
// CloseAuditSink flushes in-flight remote forwards and closes the audit writer.
|
||||
// It is invoked from an unconditional defer in Execute so the drain happens for
|
||||
// both successful and failed commands (Cobra skips PersistentPostRunE when RunE
|
||||
// returns an error). The sync.Once makes repeated calls safe.
|
||||
func CloseAuditSink() {
|
||||
auditCloseOnce.Do(func() {
|
||||
if sharedAuditSink == nil {
|
||||
return
|
||||
}
|
||||
if err := sharedAuditSink.Close(); err != nil {
|
||||
auditReport("close failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// auditReport routes non-fatal audit-subsystem diagnostics to the structured
|
||||
// file log (always, when available) and to stderr when DWS_AUDIT_DEBUG is set,
|
||||
// so init/write/forward failures are observable instead of silently swallowed.
|
||||
func auditReport(format string, args ...any) {
|
||||
msg := "audit: " + fmt.Sprintf(format, args...)
|
||||
if l := FileLoggerInstance(); l != nil {
|
||||
l.Warn(msg)
|
||||
}
|
||||
if audit.DebugEnabled() {
|
||||
fmt.Fprintln(os.Stderr, "[dws] "+msg)
|
||||
}
|
||||
}
|
||||
|
||||
// auditIdentity resolves the Actor for the active runtime profile. The result
|
||||
// is cached per-profile so a profile switch within a long-running process (e.g.
|
||||
// serve mode) re-resolves rather than reusing a stale identity.
|
||||
func auditIdentity() (audit.Actor, string) {
|
||||
profile := auth.RuntimeProfile()
|
||||
|
||||
auditIDMu.Lock()
|
||||
defer auditIDMu.Unlock()
|
||||
if identityLoaded && profile == cachedProfile {
|
||||
return cachedActor, cachedAgentID
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
var actor audit.Actor
|
||||
if td, err := loadTokenForProfile(configDir, profile); err == nil && td != nil {
|
||||
actor = audit.Actor{
|
||||
UserID: td.UserID,
|
||||
Name: td.UserName,
|
||||
CorpID: td.CorpID,
|
||||
CorpName: td.CorpName,
|
||||
}
|
||||
} else if err != nil {
|
||||
auditReport("resolve actor for profile %q failed: %v", profile, err)
|
||||
}
|
||||
|
||||
agentID := ""
|
||||
if id := auth.Load(configDir); id != nil {
|
||||
agentID = id.AgentID
|
||||
}
|
||||
|
||||
cachedActor, cachedAgentID, cachedProfile, identityLoaded = actor, agentID, profile, true
|
||||
return actor, agentID
|
||||
}
|
||||
|
||||
func emitAudit(sink audit.Sink, execID string, invokeStart time.Time, invocation executor.Invocation, endpoint string, retErr error, cliVersion string) {
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := sink.(audit.NopSink); ok {
|
||||
return
|
||||
}
|
||||
|
||||
actor, agentID := auditIdentity()
|
||||
|
||||
result := "success"
|
||||
var errCat, errReason string
|
||||
if retErr != nil {
|
||||
result = "error"
|
||||
errCat, errReason = classifyAuditError(retErr)
|
||||
}
|
||||
|
||||
paramsSummary := logging.SanitizeArguments(invocation.Params, 1024)
|
||||
|
||||
evt := &audit.Event{
|
||||
Timestamp: invokeStart,
|
||||
ExecutionID: execID,
|
||||
AgentID: agentID,
|
||||
Actor: actor,
|
||||
Product: invocation.CanonicalProduct,
|
||||
Command: invocation.Tool,
|
||||
Endpoint: transport.RedactURL(endpoint),
|
||||
ParamsSummary: paramsSummary,
|
||||
Result: result,
|
||||
ErrCategory: errCat,
|
||||
ErrReason: errReason,
|
||||
DurationMs: time.Since(invokeStart).Milliseconds(),
|
||||
CLIVersion: cliVersion,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
}
|
||||
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
auditReport("emit event failed (exec %s): %v", execID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyAuditError(err error) (category, reason string) {
|
||||
if err == nil {
|
||||
return "", ""
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
return string(typed.Category), typed.Reason
|
||||
}
|
||||
return "unknown", err.Error()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TestAuditIdentityReresolvesOnProfileSwitch guards the reviewer's finding that a
|
||||
// long-running process (e.g. serve mode) must attribute events to the ACTIVE
|
||||
// runtime profile rather than reusing a process-global first Actor. It also
|
||||
// asserts the per-profile cache avoids redundant token loads within one profile.
|
||||
func TestAuditIdentityReresolvesOnProfileSwitch(t *testing.T) {
|
||||
prevLoader := loadTokenForProfile
|
||||
prevProfile := auth.RuntimeProfile()
|
||||
t.Cleanup(func() {
|
||||
loadTokenForProfile = prevLoader
|
||||
auth.SetRuntimeProfile(prevProfile)
|
||||
resetAuditIdentityCache()
|
||||
})
|
||||
resetAuditIdentityCache()
|
||||
|
||||
var mu sync.Mutex
|
||||
calls := map[string]int{}
|
||||
loadTokenForProfile = func(_ /*configDir*/, profile string) (*auth.TokenData, error) {
|
||||
mu.Lock()
|
||||
calls[profile]++
|
||||
mu.Unlock()
|
||||
switch profile {
|
||||
case "orgA":
|
||||
return &auth.TokenData{UserID: "ua", UserName: "Alice", CorpID: "ca", CorpName: "CorpA"}, nil
|
||||
case "orgB":
|
||||
return &auth.TokenData{UserID: "ub", UserName: "Bob", CorpID: "cb", CorpName: "CorpB"}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
auth.SetRuntimeProfile("orgA")
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ua" || actor.CorpName != "CorpA" {
|
||||
t.Fatalf("orgA: got %+v, want Alice/CorpA", actor)
|
||||
}
|
||||
// Second call under the same profile must hit the cache (no extra load).
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ua" {
|
||||
t.Fatalf("orgA cached: got %+v", actor)
|
||||
}
|
||||
|
||||
auth.SetRuntimeProfile("orgB")
|
||||
if actor, _ := auditIdentity(); actor.UserID != "ub" || actor.CorpName != "CorpB" {
|
||||
t.Fatalf("orgB: got %+v, want Bob/CorpB (stale Actor reused?)", actor)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls["orgA"] != 1 {
|
||||
t.Fatalf("orgA loaded %d times, want 1 (cache miss?)", calls["orgA"])
|
||||
}
|
||||
if calls["orgB"] != 1 {
|
||||
t.Fatalf("orgB loaded %d times, want 1", calls["orgB"])
|
||||
}
|
||||
}
|
||||
|
||||
func resetAuditIdentityCache() {
|
||||
auditIDMu.Lock()
|
||||
defer auditIDMu.Unlock()
|
||||
cachedActor = audit.Actor{}
|
||||
cachedAgentID = ""
|
||||
cachedProfile = ""
|
||||
identityLoaded = false
|
||||
}
|
||||
|
||||
// TestCloseAuditSinkDrainsOnErrorPath guards the reviewer's V5 finding: when a
|
||||
// command's RunE returns an error, Cobra skips PersistentPostRunE, so the audit
|
||||
// drain must instead happen through the unconditional defer in Execute that calls
|
||||
// CloseAuditSink. This test wires a real forwarder-backed sink into the shared
|
||||
// slot and asserts CloseAuditSink flushes the queued forward exactly as the
|
||||
// error-path defer would, and that a second call is a harmless no-op.
|
||||
func TestCloseAuditSinkDrainsOnErrorPath(t *testing.T) {
|
||||
var delivered int64
|
||||
var releaseOnce sync.Once
|
||||
release := make(chan struct{})
|
||||
releaseFn := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release // hold the request until the drain awaits it
|
||||
atomic.AddInt64(&delivered, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer releaseFn() // LIFO: unblock any in-flight handler before srv.Close()
|
||||
|
||||
writer, err := audit.NewDateRotatingWriter(t.TempDir(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fwd := audit.NewHTTPForwarder(srv.URL, "", audit.RedactNone, nil)
|
||||
sink := audit.NewFileSink(writer, audit.NewChain(""), fwd)
|
||||
|
||||
prevSink := sharedAuditSink
|
||||
t.Cleanup(func() {
|
||||
sharedAuditSink = prevSink
|
||||
auditCloseOnce = sync.Once{}
|
||||
})
|
||||
sharedAuditSink = sink
|
||||
auditCloseOnce = sync.Once{}
|
||||
|
||||
if err := sink.Emit(&audit.Event{Timestamp: time.Unix(0, 0), Product: "calendar", Command: "event_list", Result: "error"}); err != nil {
|
||||
t.Fatalf("emit: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&delivered); got != 0 {
|
||||
t.Fatalf("forward delivered before drain: %d", got)
|
||||
}
|
||||
|
||||
// Let the held request complete, then drain via the same entry point the
|
||||
// error-path defer uses. CloseAuditSink blocks until the forward goroutine
|
||||
// observes the HTTP response, so the counter is settled when it returns.
|
||||
releaseFn()
|
||||
CloseAuditSink()
|
||||
|
||||
if got := atomic.LoadInt64(&delivered); got != 1 {
|
||||
t.Fatalf("forward not drained on error path: delivered=%d, want 1", got)
|
||||
}
|
||||
|
||||
// Idempotent: the success-path PersistentPostRunE and the defer both call it.
|
||||
CloseAuditSink()
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
newAuthMigrateKeychainCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
@@ -283,6 +284,7 @@ var (
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
@@ -521,6 +523,65 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate-keychain",
|
||||
Short: "将 macOS 系统 Keychain 登录态安全迁移到 file-DEK",
|
||||
Long: `将 dws-cli 的 legacy 与 profile 登录 token 统一重加密为 file-DEK,使 Codex 等沙箱进程与普通终端共享同一登录态。
|
||||
|
||||
迁移必须从仍可读取原登录态的系统 Keychain 模式运行。命令会先验证全部认证密文;任何认证条目不可解密时均不会写入。应用密钥等无关条目不在迁移范围内。
|
||||
先用 --dry-run 预检,确认后加 --yes 执行。`,
|
||||
Example: ` env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := cmd.Flags().GetString("to")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --to")
|
||||
}
|
||||
if strings.TrimSpace(target) != "file-dek" {
|
||||
return apperrors.NewValidation("--to 当前仅支持 file-dek")
|
||||
}
|
||||
if os.Getenv(keychain.DisableKeychainEnv) != "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"迁移必须从系统 Keychain 模式运行;请使用 `env -u %s dws auth migrate-keychain --to file-dek ...`",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
|
||||
dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run")
|
||||
yes, _ := cmd.Root().PersistentFlags().GetBool("yes")
|
||||
if !dryRun && !yes {
|
||||
return apperrors.NewValidation("迁移会重加密全部本地登录 token;请先使用 --dry-run 预检,确认后加 --yes 执行")
|
||||
}
|
||||
count, err := migrateKeychainToFileDEK(defaultConfigDir(), dryRun)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("keychain migration failed: %v", err))
|
||||
}
|
||||
|
||||
result := struct {
|
||||
Success bool `json:"success"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Target string `json:"target"`
|
||||
Entries int `json:"entries"`
|
||||
}{Success: true, DryRun: dryRun, Target: "file-dek", Entries: count}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(result)
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "预检通过:%d 个本地认证条目可迁移到 file-DEK\n", count)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "迁移完成:%d 个本地认证条目已统一使用 file-DEK\n", count)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("to", "file-dek", "目标密钥后端(当前仅支持 file-dek)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
@@ -596,7 +657,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
}
|
||||
if !authpkg.PortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
@@ -1229,11 +1290,18 @@ func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if keychain.IsCiphertextKeyMismatch(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "ciphertext_key_mismatch",
|
||||
Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证",
|
||||
Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。",
|
||||
}
|
||||
}
|
||||
if keychain.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
|
||||
Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
|
||||
}
|
||||
}
|
||||
if !keychain.IsUnavailable(err) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -232,6 +233,22 @@ func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
|
||||
if !strings.Contains(resp.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "dws auth reset") {
|
||||
t.Fatalf("hint should mention dws auth reset; response=%+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
|
||||
diagnostic := authStatusDiagnosticFromError(fmt.Errorf("load token: %w", keychain.ErrCiphertextKeyMismatch))
|
||||
if diagnostic == nil {
|
||||
t.Fatal("authStatusDiagnosticFromError() = nil")
|
||||
}
|
||||
if diagnostic.Reason != "ciphertext_key_mismatch" {
|
||||
t.Fatalf("reason = %q, want ciphertext_key_mismatch", diagnostic.Reason)
|
||||
}
|
||||
if !strings.Contains(diagnostic.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s: %q", keychain.DisableKeychainEnv, diagnostic.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
@@ -335,6 +352,91 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainDryRunAndConfirmedExecution(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
|
||||
calls := 0
|
||||
migrateKeychainToFileDEK = func(_ string, dryRun bool) (int, error) {
|
||||
calls++
|
||||
if calls == 1 && !dryRun {
|
||||
t.Fatal("first migration call should be dry-run")
|
||||
}
|
||||
if calls == 2 && dryRun {
|
||||
t.Fatal("second migration call should execute")
|
||||
}
|
||||
return 4, nil
|
||||
}
|
||||
|
||||
newRoot := func() (*cobra.Command, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
return root, &out
|
||||
}
|
||||
|
||||
root, out := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --dry-run error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":true`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("dry-run output = %q", out.String())
|
||||
}
|
||||
|
||||
root, out = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --yes error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":false`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("migration output = %q", out.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("migration calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainRequiresConfirmationAndSystemMode(t *testing.T) {
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
migrateKeychainToFileDEK = func(_ string, _ bool) (int, error) {
|
||||
t.Fatal("migration backend should not be called")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
newRoot := func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
return root
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
root := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("unconfirmed migration error = %v, want --yes guidance", err)
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "env -u") {
|
||||
t.Fatalf("file-DEK mode migration error = %v, want system-mode guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
|
||||
runner := &countingErrorRunner{}
|
||||
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
|
||||
result, err := caller.CallTool(context.Background(), "aitable-helper", "set_advanced_permission", map[string]any{"enabled": false})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool() error = %v", err)
|
||||
}
|
||||
if got := runner.calls.Load(); got != 0 {
|
||||
t.Fatalf("runner calls = %d, want 0", got)
|
||||
}
|
||||
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"dry_run":true`) {
|
||||
t.Fatalf("dry-run result = %#v", result)
|
||||
}
|
||||
|
||||
var nilAdapter *toolCallerAdapter
|
||||
if nilAdapter.DryRun() || nilAdapter.Format() != "json" {
|
||||
t.Fatal("nil adapter accessors are not safe")
|
||||
}
|
||||
if _, err := nilAdapter.CallTool(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil adapter accepted a tool call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
|
||||
fallback := &countingErrorRunner{}
|
||||
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
|
||||
result, err := runner.Run(context.Background(), executor.NewHelperInvocation(
|
||||
"test",
|
||||
"aitable",
|
||||
"tool",
|
||||
map[string]any{"id": "x"},
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !result.Invocation.DryRun || result.Response["dry_run"] != true {
|
||||
t.Fatalf("dry-run result = %#v", result)
|
||||
}
|
||||
if got := fallback.calls.Load(); got != 0 {
|
||||
t.Fatalf("fallback calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
type countingErrorRunner struct {
|
||||
calls atomic.Int64
|
||||
}
|
||||
|
||||
func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
r.calls.Add(1)
|
||||
return executor.Result{}, errors.New("runner must not be called")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
)
|
||||
|
||||
func writeEventTestAppConfig(t *testing.T, dir string, cfg authpkg.AppConfig) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal app config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(authpkg.GetAppConfigPath(dir), raw, 0o600); err != nil {
|
||||
t.Fatalf("write app config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventCredentials_PortalNormalAllowsMissingClientSecret(t *testing.T) {
|
||||
t.Setenv(authpkg.EnvClientID, "")
|
||||
t.Setenv(authpkg.EnvClientSecret, "")
|
||||
dir := t.TempDir()
|
||||
|
||||
clientID, clientSecret, err := resolveEventCredentials(dir, eventStreamTicketOptions{
|
||||
Mode: source.PortalTicketModeNormal,
|
||||
SourceID: "pre_open_source",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEventCredentials: %v", err)
|
||||
}
|
||||
if clientID != "portal-ticket-normal:pre_open_source" {
|
||||
t.Fatalf("clientID = %q, want portal-ticket-normal:pre_open_source", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
t.Fatalf("clientSecret = %q, want empty", clientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEventCredentials_PortalCustomStillRequiresClientSecret(t *testing.T) {
|
||||
t.Setenv(authpkg.EnvClientID, "")
|
||||
t.Setenv(authpkg.EnvClientSecret, "")
|
||||
dir := t.TempDir()
|
||||
writeEventTestAppConfig(t, dir, authpkg.AppConfig{ClientID: "ding-custom"})
|
||||
|
||||
_, _, err := resolveEventCredentials(dir, eventStreamTicketOptions{
|
||||
Mode: source.PortalTicketModeCustom,
|
||||
})
|
||||
if !errors.Is(err, authpkg.ErrClientSecretEmpty) {
|
||||
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type commonConsumeOptions struct {
|
||||
EventTypes []string
|
||||
Filter string
|
||||
Compact bool
|
||||
FormatRaw string
|
||||
OutputDir string
|
||||
RoutesRaw []string
|
||||
MaxEvents int
|
||||
Duration time.Duration
|
||||
Quiet bool
|
||||
Force bool
|
||||
DryRun bool
|
||||
Foreground bool
|
||||
}
|
||||
|
||||
type personalConsumeOptions struct {
|
||||
Common commonConsumeOptions
|
||||
EventKey string
|
||||
DebugRawEvents bool
|
||||
SubscribeID string
|
||||
Rule string
|
||||
Name string
|
||||
FilterJSON string
|
||||
QueryCSV string
|
||||
TTL time.Duration
|
||||
Ephemeral bool
|
||||
UserID string
|
||||
GroupID string
|
||||
ControlBaseURL string
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalListOptions struct {
|
||||
Category string
|
||||
EnabledOnly bool
|
||||
IncludePending bool
|
||||
Format string
|
||||
}
|
||||
|
||||
type personalStatusOptions struct {
|
||||
EventKey string
|
||||
Status string
|
||||
SubscribeID string
|
||||
Format string
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalStopOptions struct {
|
||||
SubscribeID string
|
||||
All bool
|
||||
ControlBaseURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type personalStreamSourceOptions struct {
|
||||
ConfigDir string
|
||||
Identity personal.Identity
|
||||
TicketMode string
|
||||
TicketURL string
|
||||
ClientIDOverride string
|
||||
}
|
||||
|
||||
func newEventSchemaCommand() *cobra.Command {
|
||||
var asIdentity string
|
||||
var formatRaw string
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <event_key>",
|
||||
Short: "显示事件 schema",
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := normalizeEventAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as != "user" {
|
||||
return fmt.Errorf("event schema is only supported with --as user")
|
||||
}
|
||||
def, ok := personal.Lookup(args[0])
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown personal event key %q", args[0])
|
||||
}
|
||||
if !def.Public {
|
||||
return personal.PublicAvailabilityError(args[0])
|
||||
}
|
||||
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
|
||||
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要查询 payload 字段定义的个人事件码",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
|
||||
items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending)
|
||||
if opts.Format == "json" {
|
||||
enc := json.NewEncoder(c.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(items)
|
||||
}
|
||||
tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION")
|
||||
for _, it := range items {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
it.EventKey, it.RuleType, it.Status, it.Description)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
if format != "json" {
|
||||
return fmt.Errorf("event schema only supports json output")
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(personal.BuildSchemaDocument(def))
|
||||
}
|
||||
|
||||
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
}
|
||||
normalised, fellback := consume.NormalizeFormat(rawFormat)
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
|
||||
if opts.Common.DryRun {
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
DryRun: true,
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||
return consume.Run(ctx, cfg)
|
||||
}
|
||||
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if sub.SubscribeID == "" {
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||
}
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
EventKey: eventKey,
|
||||
RuleType: ruleType,
|
||||
ClientID: identity.ClientID,
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
workDir, filepath.Join(workDir, "bus.log"))
|
||||
}
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
if !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = bus.Run(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = consume.Run(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if opts.DebugRawEvents {
|
||||
cfg.EventTypes = nil
|
||||
cfg.Filter = ""
|
||||
cfg.SubscribeID = ""
|
||||
return
|
||||
}
|
||||
cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes)
|
||||
cfg.Filter = opts.Common.Filter
|
||||
cfg.SubscribeID = strings.TrimSpace(subscribeID)
|
||||
}
|
||||
|
||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
|
||||
if eventKey == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(eventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
|
||||
if ruleType == "" {
|
||||
if def, ok := personal.Lookup(eventKey); ok {
|
||||
ruleType = def.RuleType
|
||||
}
|
||||
}
|
||||
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
|
||||
return sub, eventKey, ruleType, nil
|
||||
}
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
sub, err := client.CreateSubscription(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, opts.EventKey, ruleType, nil
|
||||
}
|
||||
|
||||
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
var qs busctl.EntryStatus
|
||||
if entry != nil {
|
||||
qs = busctl.QueryEntry(*entry)
|
||||
} else {
|
||||
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
|
||||
WorkDir: workDir,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
ClientIDHash: identityHash,
|
||||
IdentityHash: identityHash,
|
||||
State: busctl.BusStateNotRunning,
|
||||
Meta: &bus.Meta{
|
||||
ClientID: identity.ClientID,
|
||||
Edition: editionName,
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
},
|
||||
}}
|
||||
}
|
||||
status := opts.Status
|
||||
if status == "" || status == "all" {
|
||||
status = ""
|
||||
}
|
||||
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
|
||||
Status: status,
|
||||
EventKey: opts.EventKey,
|
||||
SubscribeID: opts.SubscribeID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("event status --as user: %w", err)
|
||||
}
|
||||
if opts.Format == "json" {
|
||||
enc := json.NewEncoder(c.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]any{
|
||||
"identity": redactedPersonalIdentity(identity, identityHash),
|
||||
"subscriptions": subs,
|
||||
"bus": qs,
|
||||
})
|
||||
}
|
||||
renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePublicPersonalEvent(eventKey string) error {
|
||||
eventKey = strings.TrimSpace(eventKey)
|
||||
if eventKey == "" {
|
||||
return nil
|
||||
}
|
||||
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
|
||||
return personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) {
|
||||
fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n",
|
||||
displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash)
|
||||
fmt.Fprintf(w, "Bus: %s", qs.Entry.State)
|
||||
if qs.Entry.HolderPID > 0 {
|
||||
fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID)
|
||||
}
|
||||
fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir)
|
||||
if len(subs) == 0 {
|
||||
fmt.Fprintln(w, "Subscriptions: none")
|
||||
} else {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE")
|
||||
for _, sub := range subs {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
renderPersonalConsumers(w, qs)
|
||||
}
|
||||
|
||||
func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) {
|
||||
if qs.Entry.State != busctl.BusStateRunning {
|
||||
fmt.Fprintln(w, "Consumers: none")
|
||||
return
|
||||
}
|
||||
if qs.Live == nil {
|
||||
fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)")
|
||||
return
|
||||
}
|
||||
if len(qs.Live.Consumers) == 0 {
|
||||
fmt.Fprintln(w, "Consumers: none")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Consumers:")
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED")
|
||||
for _, cs := range qs.Live.Consumers {
|
||||
eventKeys := strings.Join(cs.EventTypes, ",")
|
||||
if eventKeys == "" {
|
||||
eventKeys = "(catch-all)"
|
||||
}
|
||||
subscribeID := displayPersonalStatusValue(cs.SubscribeID)
|
||||
filter := displayPersonalStatusValue(cs.Filter)
|
||||
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n",
|
||||
cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func displayPersonalStatusValue(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "-"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
ctx := c.Context()
|
||||
explicitSubscribeID := strings.TrimSpace(opts.SubscribeID)
|
||||
isSingleTarget := explicitSubscribeID != ""
|
||||
if explicitSubscribeID != "" && opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if explicitSubscribeID == "" && !opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: %w", err)
|
||||
}
|
||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
for _, id := range subscribeIDs {
|
||||
if err := client.DeleteSubscription(ctx, id); err != nil {
|
||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
||||
}
|
||||
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
||||
}
|
||||
|
||||
remaining, err := personal.LoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running")
|
||||
return nil
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
|
||||
explicit = strings.TrimSpace(explicit)
|
||||
if explicit != "" && all {
|
||||
return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if explicit != "" {
|
||||
return []string{explicit}, nil
|
||||
}
|
||||
if !all {
|
||||
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
|
||||
}
|
||||
states, err := personal.LoadRunStates(workDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(states))
|
||||
for _, st := range states {
|
||||
if st.SubscribeID != "" {
|
||||
ids = append(ids, st.SubscribeID)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error {
|
||||
targets := make(map[string]struct{}, len(subscribeIDs))
|
||||
for _, id := range subscribeIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targets[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if ipcEndpoint == "" || len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
status, err := busctl.QueryStatus(ipcEndpoint)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
signalled := make(map[int]struct{})
|
||||
for _, consumer := range status.Consumers {
|
||||
if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok {
|
||||
continue
|
||||
}
|
||||
if consumer.PID <= 0 || consumer.PID == os.Getpid() {
|
||||
continue
|
||||
}
|
||||
if _, ok := signalled[consumer.PID]; ok {
|
||||
continue
|
||||
}
|
||||
proc, err := os.FindProcess(consumer.PID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
|
||||
}
|
||||
signalled[consumer.PID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
|
||||
if single && len(subscribeIDs) == 1 {
|
||||
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
|
||||
}
|
||||
|
||||
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
||||
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
if err != nil {
|
||||
return personal.Identity{}, err
|
||||
}
|
||||
tokenData, _ := authpkg.LoadTokenData(configDir)
|
||||
var corpID, userID, clientID, refreshToken string
|
||||
if tokenData != nil {
|
||||
corpID = tokenData.CorpID
|
||||
userID = tokenData.UserID
|
||||
clientID = tokenData.ClientID
|
||||
refreshToken = tokenData.RefreshToken
|
||||
}
|
||||
if corpID == "" {
|
||||
corpID = resolveRuntimeDefault(ctx, "$corpId")
|
||||
}
|
||||
if userID == "" {
|
||||
userID = resolveRuntimeDefault(ctx, "$currentUserId")
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = authpkg.ClientID()
|
||||
}
|
||||
if clientID == "" {
|
||||
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
|
||||
clientID = id
|
||||
}
|
||||
}
|
||||
if clientID == "" {
|
||||
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
|
||||
}
|
||||
sourceID := strings.TrimSpace(sourceIDOverride)
|
||||
if sourceID == "" {
|
||||
sourceID = personalEventStreamSourceID("")
|
||||
}
|
||||
localSubject := ""
|
||||
if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" {
|
||||
localSubject = personalTokenSubject("refresh", refreshToken)
|
||||
if localSubject == "" {
|
||||
localSubject = personalTokenSubject("access", accessToken)
|
||||
}
|
||||
}
|
||||
return personal.Identity{
|
||||
AccessToken: accessToken,
|
||||
LocalSubject: localSubject,
|
||||
CorpID: corpID,
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
SourceID: sourceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func personalTokenSubject(kind, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func resolveRuntimeDefault(ctx context.Context, key string) string {
|
||||
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
|
||||
if fn := fnMap()[key]; fn != nil {
|
||||
if v, ok := fn(ctx); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
mode := strings.TrimSpace(opts.TicketMode)
|
||||
if mode == "" {
|
||||
mode = "normal"
|
||||
}
|
||||
if mode != "normal" && mode != "custom" {
|
||||
return nil, fmt.Errorf("stream ticket mode must be normal or custom")
|
||||
}
|
||||
ticketURL := strings.TrimSpace(opts.TicketURL)
|
||||
if ticketURL == "" {
|
||||
ticketURL = personalEventStreamTicketURL("", opts.ConfigDir)
|
||||
}
|
||||
clientID := opts.Identity.ClientID
|
||||
clientSecret := ""
|
||||
if mode == "custom" {
|
||||
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.ClientIDOverride != "" {
|
||||
clientID = opts.ClientIDOverride
|
||||
} else if clientID == "" {
|
||||
clientID = resolvedID
|
||||
}
|
||||
clientSecret = secret
|
||||
}
|
||||
_ = ctx
|
||||
return source.NewPersonal(source.PersonalConfig{
|
||||
AccessToken: opts.Identity.AccessToken,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
SourceID: opts.Identity.SourceID,
|
||||
TicketURL: ticketURL,
|
||||
TicketMode: mode,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
})
|
||||
}
|
||||
|
||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
|
||||
args := []string{
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
}
|
||||
// Forward the organization so the detached _bus child resolves
|
||||
// credentials for the SAME profile the parent used. Without this the
|
||||
// child falls back to the default profile's token slot and fails to
|
||||
// authenticate the personal stream for a non-default `--profile`
|
||||
// (symptom: "bus child reported startup failure on ready pipe", no
|
||||
// bus.log). --profile accepts a corpId; the root pre-parses it into the
|
||||
// runtime profile before the _bus handler resolves the identity.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", cid)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
}
|
||||
if strings.TrimSpace(ticketURL) != "" {
|
||||
args = append(args, "--stream-ticket-url", ticketURL)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func personalEventTypes(eventKey string, explicit []string) []string {
|
||||
if len(explicit) > 0 {
|
||||
return explicit
|
||||
}
|
||||
if strings.TrimSpace(eventKey) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{eventKey}
|
||||
}
|
||||
|
||||
func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string {
|
||||
return map[string]string{
|
||||
"corp_id": displayIdentityPart(identity.CorpID),
|
||||
"user_id": displayIdentityPart(identity.UserID),
|
||||
"client_id": identity.ClientID,
|
||||
"source_id": identity.SourceID,
|
||||
"identity_hash": identityHash,
|
||||
}
|
||||
}
|
||||
|
||||
func displayIdentityPart(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func firstNonEmptyPersonalString(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func personalEventControlBaseURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath
|
||||
}
|
||||
|
||||
func personalEventStreamTicketURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket"
|
||||
}
|
||||
|
||||
func personalEventStreamSourceID(raw string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
|
||||
return v
|
||||
}
|
||||
return "open"
|
||||
}
|
||||
|
||||
func personalEventMCPBaseURL(configDir string) string {
|
||||
if v := configuredMCPBaseURL(configDir); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return config.DefaultMCPBaseURL
|
||||
}
|
||||
|
||||
func configuredMCPBaseURL(configDir string) string {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
configDir = defaultConfigDir()
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
|
||||
cfg := consume.Config{}
|
||||
opts := personalConsumeOptions{
|
||||
DebugRawEvents: true,
|
||||
Common: commonConsumeOptions{
|
||||
EventTypes: []string{"should-not-survive"},
|
||||
Filter: "^should-not-survive$",
|
||||
},
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
|
||||
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
|
||||
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
|
||||
cfg := consume.Config{}
|
||||
opts := personalConsumeOptions{Common: commonConsumeOptions{Filter: "^user_im_"}}
|
||||
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
|
||||
if len(cfg.EventTypes) != 1 || cfg.EventTypes[0] != "user_im_message_receive_o2o" {
|
||||
t.Fatalf("eventTypes = %#v", cfg.EventTypes)
|
||||
}
|
||||
if cfg.Filter != "^user_im_" || cfg.SubscribeID != "sub-1" {
|
||||
t.Fatalf("filter=%q subscribeID=%q", cfg.Filter, cfg.SubscribeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", "--debug-raw-events"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", personal.EventSingleChat})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
for _, name := range []string{"user", "group", "query"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("flag --%s is not registered", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"peer-user-id",
|
||||
"peer-union-id",
|
||||
"sender-user-id",
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
} {
|
||||
if cmd.Flags().Lookup(name) != nil {
|
||||
t.Fatalf("retired flag --%s is still registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"peer-user-id",
|
||||
"peer-union-id",
|
||||
"sender-user-id",
|
||||
"sender-union-id",
|
||||
"open-conversation-id",
|
||||
"keyword",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--" + name, "x"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --"+name) {
|
||||
t.Fatalf("Execute() error = %v, want unknown flag", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--as", "app", "--user", "507971"},
|
||||
{"--as", "app", "--group", "cid"},
|
||||
{"--as", "app", "--query", "报警"},
|
||||
} {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute(%v) error = %v, want public availability guard", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestResolvePersonalEventIdentityUsesCorpUserWhenAvailable(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
if identity.LocalSubject != "" {
|
||||
t.Fatalf("LocalSubject = %q, want empty when corp/user are available", identity.LocalSubject)
|
||||
}
|
||||
wantKey := "corp_user\x00corp-1\x00user-1\x00client-1\x00pre_open_source"
|
||||
if got := identity.Key(); got != wantKey {
|
||||
t.Fatalf("identity key = %q, want %q", got, wantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityFallsBackToRefreshTokenSubject(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
wantSubject := personalTokenSubject("refresh", "refresh-1")
|
||||
if identity.LocalSubject != wantSubject {
|
||||
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
|
||||
}
|
||||
if strings.Contains(identity.Key(), "refresh-1") || strings.Contains(identity.Key(), "access-1") {
|
||||
t.Fatalf("identity key leaked raw token: %q", identity.Key())
|
||||
}
|
||||
|
||||
body, err := json.Marshal(redactedPersonalIdentity(identity, "identity-hash-1"))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal redacted identity: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), wantSubject) || strings.Contains(string(body), "refresh-1") || strings.Contains(string(body), "access-1") {
|
||||
t.Fatalf("redacted identity leaked local subject/token: %s", string(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "unknown") {
|
||||
t.Fatalf("redacted identity should mark missing corp/user as unknown: %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityFallsBackToAccessTokenSubject(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
wantSubject := personalTokenSubject("access", "access-1")
|
||||
if identity.LocalSubject != wantSubject {
|
||||
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, identity, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{WorkDir: "wd", State: busctl.BusStateNotRunning},
|
||||
})
|
||||
rendered := out.String()
|
||||
if !strings.Contains(rendered, "corp=unknown user=unknown") {
|
||||
t.Fatalf("status output = %q, want unknown corp/user", rendered)
|
||||
}
|
||||
if strings.Contains(rendered, wantSubject) || strings.Contains(rendered, "access-1") {
|
||||
t.Fatalf("status output leaked local subject/token: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePersonalEventIdentityDefaultsSourceIDToOpen(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
|
||||
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
|
||||
}
|
||||
if identity.SourceID != "open" {
|
||||
t.Fatalf("SourceID = %q, want open", identity.SourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventDefaultsUseProductionWithoutMCPConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
if got := personalEventControlBaseURL("", dir); got != "https://mcp.dingtalk.com/dws" {
|
||||
t.Fatalf("personalEventControlBaseURL() = %q, want production control URL", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL("", dir); got != "https://mcp.dingtalk.com/stream/connections/ticket" {
|
||||
t.Fatalf("personalEventStreamTicketURL() = %q, want production ticket URL", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID(""); got != "open" {
|
||||
t.Fatalf("personalEventStreamSourceID() = %q, want open", got)
|
||||
}
|
||||
if got := config.GetMCPBaseURL(); got != "https://mcp.dingtalk.com" {
|
||||
t.Fatalf("config.GetMCPBaseURL() = %q, want production MCP URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventDefaultsRespectExplicitAndMCPConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://custom-mcp.example.com\n"), 0o600); err != nil {
|
||||
t.Fatalf("write mcp_url: %v", err)
|
||||
}
|
||||
|
||||
if got := personalEventControlBaseURL("", dir); got != "https://custom-mcp.example.com/dws" {
|
||||
t.Fatalf("personalEventControlBaseURL() = %q, want configured control URL", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL("", dir); got != "https://custom-mcp.example.com/stream/connections/ticket" {
|
||||
t.Fatalf("personalEventStreamTicketURL() = %q, want configured ticket URL", got)
|
||||
}
|
||||
if got := personalEventControlBaseURL(" https://override.example.com/dws/ ", dir); got != "https://override.example.com/dws" {
|
||||
t.Fatalf("explicit control URL = %q, want trimmed override", got)
|
||||
}
|
||||
if got := personalEventStreamTicketURL(" https://override.example.com/ticket/ ", dir); got != "https://override.example.com/ticket" {
|
||||
t.Fatalf("explicit ticket URL = %q, want trimmed override", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
|
||||
t.Fatalf("explicit sourceID = %q, want flag_source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSourceIDPrefersEditionOverride(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{PersonalEventSourceID: "edition_source"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
if got := personalEventStreamSourceID(""); got != "edition_source" {
|
||||
t.Fatalf("personalEventStreamSourceID() = %q, want edition_source", got)
|
||||
}
|
||||
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
|
||||
t.Fatalf("explicit sourceID = %q, want flag_source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func setupPersonalIdentityToken(t *testing.T, data *authpkg.TokenData) string {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal token data: %v", err)
|
||||
}
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(dir string) ([]byte, error) {
|
||||
if filepath.Clean(dir) != filepath.Clean(configDir) {
|
||||
t.Fatalf("LoadToken dir = %q, want %q", dir, configDir)
|
||||
}
|
||||
return raw, nil
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
return configDir
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "table", args: []string{"--as", "user"}},
|
||||
{name: "json", args: []string{"--as", "user", "--format", "json"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
assertPersonalOutputHidesSchemaIDs(t, got)
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventListDefaultsToUser(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
|
||||
t.Fatalf("list output = %s, want personal event catalog", got)
|
||||
}
|
||||
if strings.Contains(got, personal.EventFromUser) {
|
||||
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
|
||||
}
|
||||
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
|
||||
t.Fatalf("list default appears to use legacy application output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventPublicHelpHidesAppMode(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
}{
|
||||
{name: "consume", cmd: newEventConsumeCommand()},
|
||||
{name: "list", cmd: newEventListCommand()},
|
||||
{name: "schema", cmd: newEventSchemaCommand()},
|
||||
{name: "status", cmd: newEventStatusCommand()},
|
||||
{name: "stop", cmd: newEventStopCommand()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
tc.cmd.SetOut(&out)
|
||||
tc.cmd.SetArgs([]string{"--help"})
|
||||
if tc.name == "schema" {
|
||||
tc.cmd.SetArgs([]string{personal.EventSingleChat, "--help"})
|
||||
}
|
||||
if err := tc.cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, hidden := range []string{"--as", "user|app", "应用事件" + " Stream"} {
|
||||
if strings.Contains(got, hidden) {
|
||||
t.Fatalf("%s help leaked %q:\n%s", tc.name, hidden, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventListAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--all are not supported for personal events") {
|
||||
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventAsAppRejected(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, cmd := range []*cobra.Command{
|
||||
newEventListCommand(),
|
||||
newEventStatusCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventStopCommand(),
|
||||
newEventSchemaCommand(),
|
||||
} {
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app"})
|
||||
if cmd.Use == "schema <event_key>" {
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--as", "app"})
|
||||
}
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("%s Execute() error = %v, want public availability guard", cmd.Use, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStatusAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
|
||||
cmd := newEventStatusCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--all", "--fail-on-orphan"})
|
||||
err := cmd.Execute()
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), "--all") ||
|
||||
!strings.Contains(err.Error(), "--fail-on-orphan") ||
|
||||
!strings.Contains(err.Error(), "not supported for personal events") {
|
||||
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "default", args: []string{personal.EventSingleChat, "--as", "user"}},
|
||||
{name: "json", args: []string{personal.EventSingleChat, "--as", "user", "--format", "json"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
assertPersonalOutputHidesSchemaIDs(t, out.String())
|
||||
if strings.Contains(out.String(), "Schemas") {
|
||||
t.Fatalf("schema output contains Schemas line: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
for _, eventKey := range []string{
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
personal.EventInChat,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output for %s is not JSON: %v\n%s", eventKey, err, got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"event_key",
|
||||
"display_name",
|
||||
"description",
|
||||
"category",
|
||||
"rule_type",
|
||||
"required_params",
|
||||
"jq_root_path",
|
||||
"schema",
|
||||
"event_id",
|
||||
"timestamp",
|
||||
"subscribe_id",
|
||||
"content",
|
||||
"sender",
|
||||
"sender_open_dingtalk_id",
|
||||
"conversation_id",
|
||||
"message_id",
|
||||
"create_time",
|
||||
"event_time",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{
|
||||
"message.text",
|
||||
"chat.openConversationId",
|
||||
"sender.userId",
|
||||
"sender.unionId",
|
||||
"auth",
|
||||
"resolved_output_schema",
|
||||
"decoded_data_schema",
|
||||
"filter_schema",
|
||||
"payload_schema",
|
||||
"output_schema",
|
||||
"data_json_path",
|
||||
"headers",
|
||||
"audit",
|
||||
"tenant",
|
||||
"subject",
|
||||
"traceId",
|
||||
"msgIdMetaq",
|
||||
"at_users",
|
||||
"sender_user_id",
|
||||
} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
|
||||
}
|
||||
}
|
||||
if doc["jq_root_path"] != ".data | fromjson" {
|
||||
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
|
||||
}
|
||||
schema, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema = %#v, want object", doc["schema"])
|
||||
}
|
||||
props, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
if _, ok := props["content"].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSchemaDefaultsToUser(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{personal.EventSingleChat})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != personal.EventSingleChat {
|
||||
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "schema",
|
||||
cmd: newEventSchemaCommand(),
|
||||
args: []string{personal.EventFromUser},
|
||||
},
|
||||
{
|
||||
name: "consume",
|
||||
cmd: newEventConsumeCommand(),
|
||||
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
cmd: newEventStatusCommand(),
|
||||
args: []string{"--event", personal.EventFromUser},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.cmd.SilenceUsage = true
|
||||
tc.cmd.SilenceErrors = true
|
||||
tc.cmd.SetArgs(tc.args)
|
||||
err := tc.cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want not publicly available", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{personal.EventSingleChat, "--format", "table"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "event schema only supports json output") {
|
||||
t.Fatalf("Execute() error = %v, want json-only format validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventAsBotRejected(t *testing.T) {
|
||||
cmd := newEventListCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPersonalOutputHidesSchemaIDs(t *testing.T, out string) {
|
||||
t.Helper()
|
||||
for _, leaked := range []string{"SCHEMA_IDS", "schema_ids", "im_msg_23", "im_msg_29"} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Fatalf("output leaked %q: %s", leaked, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
func TestRenderPersonalStatusTextShowsConsumersWithoutSubscriptions(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
SourceID: "source-1",
|
||||
}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateRunning,
|
||||
HolderPID: 100,
|
||||
},
|
||||
Live: &transport.StatusResp{
|
||||
Consumers: []transport.StatusConsumer{
|
||||
{
|
||||
PID: 12345,
|
||||
EventTypes: []string{"user_im_message_receive_o2o"},
|
||||
SubscribeID: "subId-1",
|
||||
Filter: "content",
|
||||
Received: 3,
|
||||
Dropped: 1,
|
||||
},
|
||||
{
|
||||
PID: 12346,
|
||||
Received: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"Subscriptions: none",
|
||||
"Consumers:",
|
||||
"PID",
|
||||
"EVENT_KEYS",
|
||||
"SUBSCRIBE_ID",
|
||||
"RECEIVED",
|
||||
"DROPPED",
|
||||
"12345",
|
||||
"user_im_message_receive_o2o",
|
||||
"subId-1",
|
||||
"content",
|
||||
"3",
|
||||
"1",
|
||||
"(catch-all)",
|
||||
"-",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("status output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPersonalStatusTextConsumersUnavailableWhenRPCFails(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateRunning,
|
||||
HolderPID: 100,
|
||||
},
|
||||
})
|
||||
if got := out.String(); !strings.Contains(got, "Consumers: unavailable (status RPC failed)") {
|
||||
t.Fatalf("status output = %q, want unavailable consumers", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPersonalStatusTextConsumersNoneWhenBusNotRunning(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
|
||||
Entry: busctl.BusEntry{
|
||||
WorkDir: "wd",
|
||||
State: busctl.BusStateNotRunning,
|
||||
},
|
||||
})
|
||||
if got := out.String(); !strings.Contains(got, "Consumers: none") {
|
||||
t.Fatalf("status output = %q, want no consumers", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
event := newEventCommand()
|
||||
unregistered := &cobra.Command{Use: "unregistered", Run: func(*cobra.Command, []string) {}}
|
||||
root.AddCommand(event, unregistered)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if event.Hidden {
|
||||
t.Fatal("built-in event command was hidden by the direct-runtime visibility filter")
|
||||
}
|
||||
if !unregistered.Hidden {
|
||||
t.Fatal("control command outside the built-in/direct-runtime sets remained visible")
|
||||
}
|
||||
|
||||
var leaves []string
|
||||
for _, command := range event.Commands() {
|
||||
if command.Hidden || !command.Runnable() {
|
||||
continue
|
||||
}
|
||||
leaves = append(leaves, command.Name())
|
||||
}
|
||||
sort.Strings(leaves)
|
||||
want := []string{"consume", "list", "schema", "status", "stop"}
|
||||
if len(leaves) != len(want) {
|
||||
t.Fatalf("public event leaves = %v, want %v", leaves, want)
|
||||
}
|
||||
for index := range want {
|
||||
if leaves[index] != want[index] {
|
||||
t.Fatalf("public event leaves = %v, want %v", leaves, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
builtIn := newEventCommand()
|
||||
root.AddCommand(builtIn)
|
||||
|
||||
pluginEvent := &cobra.Command{Use: "event", Run: func(*cobra.Command, []string) {}}
|
||||
addPluginCommandsSafe(root, []*cobra.Command{pluginEvent})
|
||||
|
||||
var eventCommands []*cobra.Command
|
||||
for _, command := range root.Commands() {
|
||||
if command.Name() == "event" {
|
||||
eventCommands = append(eventCommands, command)
|
||||
}
|
||||
}
|
||||
if len(eventCommands) != 1 || eventCommands[0] != builtIn {
|
||||
t.Fatalf("event command after plugin registration = %p (%d matches), want built-in %p", firstEventCommand(eventCommands), len(eventCommands), builtIn)
|
||||
}
|
||||
if pluginEvent.Parent() != nil {
|
||||
t.Fatal("conflicting plugin event command was attached to the root")
|
||||
}
|
||||
}
|
||||
|
||||
func firstEventCommand(commands []*cobra.Command) *cobra.Command {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
return commands[0]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
// shape: --max-events / --duration are the lifecycle control.
|
||||
func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
|
||||
if shouldWatchStdinEOF(1, 0) {
|
||||
t.Error("--max-events set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(0, 5*time.Second) {
|
||||
t.Error("--duration set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(3, 2*time.Second) {
|
||||
t.Error("both bounds set should not arm stdin watcher")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the detached _bus child must receive --profile so it resolves
|
||||
// credentials for the same organization as the parent. Missing it made a
|
||||
// non-default `--profile` consume fail with "bus child reported startup
|
||||
// failure on ready pipe" (no bus.log).
|
||||
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
args := personalBusSpawnArgs(personal.Identity{
|
||||
CorpID: "dinga626d60c1128d449",
|
||||
SourceID: "open",
|
||||
}, "", "")
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
|
||||
}
|
||||
|
||||
// No CorpID → no --profile appended (avoid an empty flag value).
|
||||
bare := personalBusSpawnArgs(personal.Identity{SourceID: "open"}, "", "")
|
||||
for _, a := range bare {
|
||||
if a == "--profile" {
|
||||
t.Errorf("must not append --profile when CorpID is empty; got %v", bare)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventStopRequiresTypedConfirmationBeforeMutation(t *testing.T) {
|
||||
root, _ := newEventStopSafetyRoot()
|
||||
root.SetArgs([]string{"event", "stop", "sub-1"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("event stop without --yes or --dry-run unexpectedly succeeded")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("event stop confirmation error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
if appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("event stop confirmation reason = %q, want confirmation_required", appErr.Reason)
|
||||
}
|
||||
for _, recoveryFlag := range []string{"--dry-run", "--yes"} {
|
||||
if !strings.Contains(err.Error(), recoveryFlag) {
|
||||
t.Fatalf("event stop confirmation error %q does not explain %s", err, recoveryFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopDryRunPrecedesConfirmationAndReturnsPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantAll bool
|
||||
wantSubscribeID string
|
||||
}{
|
||||
{name: "single subscription", args: []string{"event", "stop", "sub-1", "--dry-run"}, wantSubscribeID: "sub-1"},
|
||||
{name: "all subscriptions", args: []string{"--dry-run", "event", "stop", "--all"}, wantAll: true},
|
||||
{name: "dry run wins over yes", args: []string{"event", "stop", "sub-2", "--yes", "--dry-run"}, wantSubscribeID: "sub-2"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root, stdout := newEventStopSafetyRoot()
|
||||
root.SetArgs(test.args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("event stop dry-run error = %v", err)
|
||||
}
|
||||
var preview map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &preview); err != nil {
|
||||
t.Fatalf("decode event stop dry-run preview: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if preview["dry_run"] != true || preview["action"] != "event.stop" || preview["identity"] != "user" {
|
||||
t.Fatalf("event stop dry-run preview = %#v", preview)
|
||||
}
|
||||
if got, _ := preview["all"].(bool); got != test.wantAll {
|
||||
t.Fatalf("event stop dry-run all = %v, want %v", got, test.wantAll)
|
||||
}
|
||||
if got, _ := preview["subscribe_id"].(string); got != test.wantSubscribeID {
|
||||
t.Fatalf("event stop dry-run subscribe_id = %q, want %q", got, test.wantSubscribeID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "missing target", args: []string{"event", "stop", "--dry-run"}, want: "subscribe_id is required unless --all is set"},
|
||||
{name: "conflicting targets", args: []string{"event", "stop", "sub-1", "--all", "--dry-run"}, want: "subscribe_id and --all are mutually exclusive"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root, _ := newEventStopSafetyRoot()
|
||||
root.SetArgs(test.args)
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("event stop dry-run validation error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newEventStopSafetyRoot() (*cobra.Command, *bytes.Buffer) {
|
||||
stdout := &bytes.Buffer{}
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
root.PersistentFlags().Bool("yes", false, "confirm execution")
|
||||
event := &cobra.Command{Use: "event"}
|
||||
event.AddCommand(newEventStopCommand())
|
||||
root.AddCommand(event)
|
||||
return root, stdout
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestEventStopHelpDescribesPersonalSubscription(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"stop [subscribe_id]",
|
||||
"取消个人事件订阅并停止本地消费",
|
||||
"取消个人事件订阅并停止本地消费,清理对应本地消费状态",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, stale := range []string{"优雅停止 bus 守护进程", strings.Join([]string{"--as", "app"}, " "), "应用事件"} {
|
||||
if strings.Contains(got, stale) {
|
||||
t.Fatalf("help still contains stale public app wording %q:\n%s", stale, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopRequiresSubscribeIDOrAll(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
|
||||
t.Fatalf("Execute() error = %v, want subscribe_id requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopSubscribeIDAndAllAreMutuallyExclusive(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"subId-1", "--all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "subscribe_id and --all are mutually exclusive") {
|
||||
t.Fatalf("Execute() error = %v, want mutual exclusion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopAsAppRejectsSubscribeID(t *testing.T) {
|
||||
cmd := newEventStopCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--as", "app", "subId-1"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
|
||||
t.Fatalf("Execute() error = %v, want public availability guard", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalStopTargets(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-b"}); err != nil {
|
||||
t.Fatalf("UpsertRunState() error = %v", err)
|
||||
}
|
||||
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-a"}); err != nil {
|
||||
t.Fatalf("UpsertRunState() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := personalStopTargets(workDir, "sub-explicit", false)
|
||||
if err != nil {
|
||||
t.Fatalf("personalStopTargets(explicit) error = %v", err)
|
||||
}
|
||||
if want := []string{"sub-explicit"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("explicit targets = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got, err = personalStopTargets(workDir, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("personalStopTargets(all) error = %v", err)
|
||||
}
|
||||
if want := []string{"sub-a", "sub-b"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("all targets = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
if _, err := personalStopTargets(workDir, "", false); err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
|
||||
t.Fatalf("personalStopTargets(no target) error = %v, want required error", err)
|
||||
}
|
||||
if _, err := personalStopTargets(workDir, "sub-explicit", true); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("personalStopTargets(explicit+all) error = %v, want mutual exclusion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintPersonalStopResult(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printPersonalStopResult(&out, []string{"sub-1"}, true, "personal bus stopped")
|
||||
if got := out.String(); got != "cancelled personal subscription sub-1; personal bus stopped\n" {
|
||||
t.Fatalf("single output = %q", got)
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
printPersonalStopResult(&out, []string{"sub-1", "sub-2"}, false, "personal bus still running")
|
||||
if got := out.String(); got != "cancelled 2 personal subscription(s); personal bus still running\n" {
|
||||
t.Fatalf("multi output = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP
|
||||
// server's tools/list LIVE (by source) and projects each tool into a
|
||||
// cli.HelperToolSchema (name, description, inputSchema properties/required). It
|
||||
// is injected into the schema command so the cli package can render
|
||||
// `dws schema dev.*` from real server schema without importing app/transport.
|
||||
//
|
||||
// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc"
|
||||
// backs `dws dev doc search` (endpoint resolved dynamically, see
|
||||
// helperSourceEndpoint). Results are memoized per source per process so
|
||||
// repeated `dws schema dev.*` hit the network at most once per source. A failed
|
||||
// fetch is not cached, allowing a later retry within the same process.
|
||||
func newHelperToolFetcher() cli.HelperToolFetcher {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cached = map[string]map[string]cli.HelperToolSchema{}
|
||||
)
|
||||
return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) {
|
||||
mu.Lock()
|
||||
if got, ok := cached[source]; ok {
|
||||
mu.Unlock()
|
||||
return got, nil
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
endpoint, err := helperSourceEndpoint(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemas, err := fetchHelperToolSchemas(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
cached[source] = schemas
|
||||
mu.Unlock()
|
||||
return schemas, nil
|
||||
}
|
||||
}
|
||||
|
||||
// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev
|
||||
// app) is pinned in source (devappMCPEndpoint, derived from the active gateway
|
||||
// base — production by default, pre when ~/.dws/mcp_url points at pre); other
|
||||
// sources (e.g. devdoc) are resolved the same way the runner resolves a product
|
||||
// endpoint — env override → discovery → edition StaticServers/SupplementServers.
|
||||
func helperSourceEndpoint(source string) (string, error) {
|
||||
switch source {
|
||||
case "", "op-app", "devapp":
|
||||
return devappMCPEndpoint(), nil
|
||||
default:
|
||||
if endpoint, ok := directRuntimeEndpoint(source, ""); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchHelperToolSchemas performs the live tools/list call against endpoint and
|
||||
// converts the descriptors. Auth and identity headers are resolved the same way
|
||||
// the runner does for direct-runtime invocations.
|
||||
func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) {
|
||||
token := resolveRuntimeAuthToken(ctx, "")
|
||||
headers := resolveIdentityHeaders()
|
||||
client := transport.NewClient(nil).WithAuth(token, headers)
|
||||
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]cli.HelperToolSchema, len(result.Tools))
|
||||
for _, td := range result.Tools {
|
||||
out[td.Name] = cli.HelperToolSchema{
|
||||
Name: td.Name,
|
||||
Description: td.Description,
|
||||
Properties: inputSchemaProperties(td.InputSchema),
|
||||
Required: inputSchemaRequired(td.InputSchema),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inputSchemaProperties pulls the "properties" object out of a deserialized
|
||||
// MCP inputSchema map. Returns an empty (non-nil) map when absent.
|
||||
func inputSchemaProperties(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
if props == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// inputSchemaRequired pulls the "required" string list out of a deserialized
|
||||
// MCP inputSchema map.
|
||||
func inputSchemaRequired(schema map[string]any) []string {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -14,11 +14,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
|
||||
"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/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -28,6 +31,17 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller)
|
||||
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)
|
||||
}
|
||||
// Built-in + user shortcuts (`dws <service> +<command>`) 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()...)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,10 @@ func TestPrintPatAuthError_HumanReadable(t *testing.T) {
|
||||
|
||||
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
// PAT browser policy is user-configurable. Isolate the config directory so
|
||||
// this serializer test exercises the built-in CLI-owned default instead of
|
||||
// inheriting the developer's ~/.dws/pat_policy.json.
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
var buf strings.Builder
|
||||
scopeErr := &PatScopeError{
|
||||
Identity: "user",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
)
|
||||
|
||||
func isolatePluginRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
dynamicMu.Lock()
|
||||
previousEndpoints := dynamicEndpoints
|
||||
previousProducts := dynamicProducts
|
||||
previousAliases := dynamicAliases
|
||||
previousToolEndpoints := dynamicToolEndpoints
|
||||
dynamicEndpoints = nil
|
||||
dynamicProducts = nil
|
||||
dynamicAliases = nil
|
||||
dynamicToolEndpoints = nil
|
||||
dynamicMu.Unlock()
|
||||
|
||||
stdioMu.Lock()
|
||||
previousStdio := stdioClients
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
stdioMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
StopAllStdioClients()
|
||||
dynamicMu.Lock()
|
||||
dynamicEndpoints = previousEndpoints
|
||||
dynamicProducts = previousProducts
|
||||
dynamicAliases = previousAliases
|
||||
dynamicToolEndpoints = previousToolEndpoints
|
||||
dynamicMu.Unlock()
|
||||
stdioMu.Lock()
|
||||
stdioClients = previousStdio
|
||||
stdioMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
registerPluginHTTPServer(mcptypes.ServerDescriptor{
|
||||
Key: "offline-http",
|
||||
Endpoint: server.URL,
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
ID: "offline-http",
|
||||
Command: "offline-http",
|
||||
},
|
||||
})
|
||||
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("plugin endpoint calls during registration = %d, want 0", got)
|
||||
}
|
||||
if endpoint, ok := directRuntimeEndpoint("offline-http", ""); !ok || endpoint != server.URL {
|
||||
t.Fatalf("registered endpoint = (%q, %v), want (%q, true)", endpoint, ok, server.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
marker := t.TempDir() + "/started"
|
||||
client := transport.NewStdioClient("/bin/sh", []string{
|
||||
"-c", fmt.Sprintf("printf started > %q", marker),
|
||||
}, nil)
|
||||
p := &plugin.Plugin{
|
||||
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
|
||||
Root: t.TempDir(),
|
||||
}
|
||||
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
|
||||
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("stdio process started during registration: stat error = %v", err)
|
||||
}
|
||||
if descriptor.Endpoint != StdioEndpoint("lazy-stdio", "local") {
|
||||
t.Fatalf("descriptor endpoint = %q", descriptor.Endpoint)
|
||||
}
|
||||
if _, ok := LookupStdioClient("lazy-stdio/local"); !ok {
|
||||
t.Fatal("stdio client was not registered for lazy execution")
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
|
||||
@@ -73,25 +71,11 @@ func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes
|
||||
return overlay
|
||||
}
|
||||
|
||||
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
|
||||
// server using only its manifest + overlay.json.
|
||||
//
|
||||
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
|
||||
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
|
||||
// registration (legacy path).
|
||||
//
|
||||
// Dynamic command building has been removed; this now simply registers the
|
||||
// server descriptor and returns nil commands.
|
||||
func registerStdioServerFromOverlay(
|
||||
p *plugin.Plugin,
|
||||
sc plugin.StdioServerClient,
|
||||
runner executor.Runner,
|
||||
) ([]*cobra.Command, mcptypes.ServerDescriptor, bool) {
|
||||
// registerStdioServerFromManifest registers an endpoint descriptor and an
|
||||
// unstarted client from versioned plugin metadata. Tool discovery is not part
|
||||
// of command-tree construction; execution starts and initializes the client.
|
||||
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
if len(overlay.ToolOverrides) == 0 {
|
||||
return nil, mcptypes.ServerDescriptor{}, false
|
||||
}
|
||||
|
||||
descriptor := mcptypes.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
@@ -105,11 +89,8 @@ func registerStdioServerFromOverlay(
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from overlay",
|
||||
slog.Debug("plugin: stdio server registered from manifest",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"toolOverrides", len(overlay.ToolOverrides))
|
||||
|
||||
// Dynamic command tree building has been removed.
|
||||
_ = runner
|
||||
return nil, descriptor, true
|
||||
return descriptor
|
||||
}
|
||||
|
||||
+29
-286
@@ -24,7 +24,6 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -39,7 +38,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -66,6 +65,8 @@ func Execute() (exitCode int) {
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
CloseAuditSink() // Drain async audit forwards on all exit paths,
|
||||
// including command errors where Cobra skips PersistentPostRunE.
|
||||
timing.PrintIfEnabled()
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
}()
|
||||
@@ -335,6 +336,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
CloseFileLogger()
|
||||
return closeOutputSink(cmd)
|
||||
},
|
||||
@@ -345,7 +347,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
schemaCmd := newSchemaCommand(loader)
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
mcpCmd.Hidden = true
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
// 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))
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(patCaller),
|
||||
@@ -356,11 +361,14 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newCatalogCommand(loader),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
usage.NewShortcutCommand(),
|
||||
schemaCmd,
|
||||
mcpCmd,
|
||||
}
|
||||
@@ -384,7 +392,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
fn(root, caller)
|
||||
deduplicateCommands(root)
|
||||
}
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
configureRootHelp(root)
|
||||
// Set custom flag error handler for better UX
|
||||
@@ -538,7 +545,7 @@ func newVersionCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
|
||||
return cli.NewSchemaCommand(loader)
|
||||
}
|
||||
|
||||
// buildMCPCommandFn is a test seam for newMCPCommand.
|
||||
@@ -559,10 +566,12 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"api": true,
|
||||
"audit": true,
|
||||
"cache": true,
|
||||
"config": true,
|
||||
"dev": true,
|
||||
"doctor": true,
|
||||
"event": true,
|
||||
"completion": true,
|
||||
"skill": true,
|
||||
"plugin": true,
|
||||
@@ -593,9 +602,9 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
// not override. This protects core CLI functionality from being hijacked
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "api": true, "login": true, "logout": true,
|
||||
"auth": true, "api": true, "audit": true, "login": true, "logout": true,
|
||||
"plugin": true, "profile": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "completion": true,
|
||||
"config": true, "doctor": true, "event": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
"schema": true, "mcp": true, "help": true,
|
||||
}
|
||||
@@ -667,43 +676,6 @@ func deduplicateCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// pluginColdTimeouts holds the cold-path discovery budget for plugin MCP
|
||||
// servers. Timeouts only apply to the *first* discovery for a given
|
||||
// plugin/server; subsequent startups take the warm cache path and bypass
|
||||
// the network entirely.
|
||||
type pluginColdTimeouts struct {
|
||||
httpNoAuth time.Duration
|
||||
httpAuth time.Duration
|
||||
stdio time.Duration
|
||||
}
|
||||
|
||||
// resolvePluginColdTimeouts returns the cold-discovery budget for plugin MCP
|
||||
// servers, applying the DWS_PLUGIN_COLD_TIMEOUT override when set. Defaults
|
||||
// are tuned so healthy cross-region HTTP endpoints succeed on a cold start
|
||||
// and Python/Node-based stdio plugins have headroom for interpreter load,
|
||||
// while an unreachable host still surrenders in bounded time.
|
||||
func resolvePluginColdTimeouts() pluginColdTimeouts {
|
||||
t := pluginColdTimeouts{
|
||||
httpNoAuth: 1 * time.Second,
|
||||
httpAuth: 1500 * time.Millisecond,
|
||||
stdio: 2 * time.Second,
|
||||
}
|
||||
raw := strings.TrimSpace(os.Getenv(cli.PluginColdTimeoutEnv))
|
||||
if raw == "" {
|
||||
return t
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
slog.Warn("plugin: ignoring invalid DWS_PLUGIN_COLD_TIMEOUT",
|
||||
"value", raw, "error", err)
|
||||
return t
|
||||
}
|
||||
t.httpNoAuth = d
|
||||
t.httpAuth = d
|
||||
t.stdio = d
|
||||
return t
|
||||
}
|
||||
|
||||
func configureOutputSink(cmd *cobra.Command) error {
|
||||
if local := cmd.LocalFlags().Lookup("output"); local != nil {
|
||||
return nil
|
||||
@@ -798,11 +770,10 @@ func CloseFileLogger() {
|
||||
}
|
||||
}
|
||||
|
||||
// loadPlugins scans plugin directories, injects their MCP servers into
|
||||
// the dynamic server registry, and registers their pipeline hooks.
|
||||
// This runs before legacy command construction so that plugin servers
|
||||
// are available for EnvironmentLoader.Load().
|
||||
func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
|
||||
// loadPlugins registers versioned plugin manifests, stdio clients, hooks, and
|
||||
// skills. It deliberately does not initialize MCP transports or call
|
||||
// tools/list while constructing the command tree.
|
||||
func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
||||
pluginLoader := plugin.NewLoader(RawVersion())
|
||||
|
||||
// 0a. Inject plugin config values from settings.json as environment
|
||||
@@ -832,96 +803,21 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
|
||||
allPlugins := append(userPlugins, devPlugins...)
|
||||
|
||||
// 3. Discover tools from streamable-http servers and build CLI commands.
|
||||
// Third-party servers with auth headers are discovered in parallel
|
||||
// to avoid sequential 10s timeouts when multiple remote servers exist.
|
||||
var pluginCmds []*cobra.Command
|
||||
tc := transport.NewClient(nil)
|
||||
|
||||
// Collect all server descriptors and register auth first (fast, no I/O).
|
||||
type pluginServer struct {
|
||||
plugin *plugin.Plugin
|
||||
srv mcptypes.ServerDescriptor
|
||||
}
|
||||
var httpServers []pluginServer
|
||||
|
||||
// 3. Register HTTP descriptors and authentication from the manifest.
|
||||
for _, p := range allPlugins {
|
||||
for _, srv := range p.ToServerDescriptors() {
|
||||
AppendDynamicServer(srv)
|
||||
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
}
|
||||
|
||||
if srv.HasCLIMeta {
|
||||
httpServers = append(httpServers, pluginServer{plugin: p, srv: srv})
|
||||
}
|
||||
registerPluginHTTPServer(srv)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all stdio clients up front so HTTP + stdio discovery can run
|
||||
// concurrently — the slowest plugin (typically an unreachable HTTP
|
||||
// endpoint hitting its dial timeout) dominates the parallel wall-clock,
|
||||
// not the sum of every plugin's cold timeout.
|
||||
type stdioEntry struct {
|
||||
plugin *plugin.Plugin
|
||||
sc plugin.StdioServerClient
|
||||
}
|
||||
var stdioEntries []stdioEntry
|
||||
// 4. Register stdio descriptors and unstarted clients. The subprocess is
|
||||
// started and initialized only when a command is actually executed.
|
||||
for _, p := range allPlugins {
|
||||
for _, sc := range p.StdioClients(userCtx) {
|
||||
// Use background context so the subprocess lives for the CLI
|
||||
// process lifetime (not killed by a short timeout).
|
||||
if err := sc.Client.Start(context.Background()); err != nil {
|
||||
slog.Warn("plugin: failed to start stdio server",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
continue
|
||||
}
|
||||
stdioEntries = append(stdioEntries, stdioEntry{plugin: p, sc: sc})
|
||||
registerStdioServerFromManifest(p, sc)
|
||||
}
|
||||
}
|
||||
|
||||
coldTimeouts := resolvePluginColdTimeouts()
|
||||
|
||||
// Phase A: stdio overlay-first registration (synchronous, no I/O).
|
||||
// Plugins whose overlay.json declares ToolOverrides register their
|
||||
// server descriptor up-front from manifest metadata alone.
|
||||
var legacyStdioEntries []stdioEntry
|
||||
for _, e := range stdioEntries {
|
||||
_, _, ok := registerStdioServerFromOverlay(e.plugin, e.sc, runner)
|
||||
if !ok {
|
||||
legacyStdioEntries = append(legacyStdioEntries, e)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Phase B: fan out discovery in parallel.
|
||||
httpResults := make([][]*cobra.Command, len(httpServers))
|
||||
legacyStdioResults := make([][]*cobra.Command, len(legacyStdioEntries))
|
||||
var wg sync.WaitGroup
|
||||
for i, ps := range httpServers {
|
||||
wg.Add(1)
|
||||
go func(idx int, ps pluginServer) {
|
||||
defer wg.Done()
|
||||
httpResults[idx] = registerHTTPServer(ps.plugin, ps.srv, tc, runner, coldTimeouts)
|
||||
}(i, ps)
|
||||
}
|
||||
// legacy stdio: discovery-first (commands depend on tool list).
|
||||
for i, e := range legacyStdioEntries {
|
||||
wg.Add(1)
|
||||
go func(idx int, e stdioEntry) {
|
||||
defer wg.Done()
|
||||
legacyStdioResults[idx] = registerStdioServer(e.plugin, e.sc, runner, coldTimeouts)
|
||||
}(i, e)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, cmds := range httpResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
for _, cmds := range legacyStdioResults {
|
||||
pluginCmds = append(pluginCmds, cmds...)
|
||||
}
|
||||
|
||||
// 5. Register plugin hooks into pipeline engine
|
||||
if engine != nil {
|
||||
for _, p := range allPlugins {
|
||||
@@ -950,89 +846,14 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
)
|
||||
}
|
||||
|
||||
return pluginCmds
|
||||
}
|
||||
|
||||
// registerHTTPServer discovers tools from a streamable-http MCP server and
|
||||
// registers the server. Dynamic command building has been removed; this now
|
||||
// simply registers the server descriptor for direct runtime dispatch.
|
||||
func registerHTTPServer(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
|
||||
tools := discoverHTTPTools(p, srv, tc, timeouts)
|
||||
return buildHTTPCommandsFromTools(srv, tools, runner)
|
||||
}
|
||||
|
||||
// discoverHTTPTools performs the blocking Initialize + ListTools handshake
|
||||
// for an HTTP MCP server and returns the discovered tools. Returns nil on
|
||||
// any transport/protocol error; errors are logged at Debug level.
|
||||
func discoverHTTPTools(p *plugin.Plugin, srv mcptypes.ServerDescriptor, tc *transport.Client, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||||
// Cold-path budget. An unreachable endpoint will burn the full window
|
||||
// via the TCP dial timeout; a healthy localhost/third-party endpoint
|
||||
// typically responds in <200 ms. Third-party servers with auth get a
|
||||
// slightly larger window to accommodate TLS + auth RTT. Operators with
|
||||
// cross-region endpoints can relax the window via DWS_PLUGIN_COLD_TIMEOUT.
|
||||
// TODO(remove-discovery): plugin discovery currently has no warm cache, so
|
||||
// unreachable endpoints still pay this timeout during command startup.
|
||||
timeout := timeouts.httpNoAuth
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
timeout = timeouts.httpAuth
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
discoveryClient := tc
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
discoveryClient = buildPluginAuthClient(tc, srv)
|
||||
}
|
||||
|
||||
if _, err := discoveryClient.Initialize(ctx, srv.Endpoint); err != nil {
|
||||
slog.Debug("plugin: http server offline, skipping tool discovery",
|
||||
"plugin", p.Manifest.Name, "server", srv.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
toolsResult, err := discoveryClient.ListTools(ctx, srv.Endpoint)
|
||||
if err != nil {
|
||||
slog.Debug("plugin: http ListTools failed",
|
||||
"plugin", p.Manifest.Name, "server", srv.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
return toolsResult.Tools
|
||||
}
|
||||
|
||||
// buildHTTPCommandsFromTools registers the server for direct runtime
|
||||
// dispatch. Dynamic command tree building has been removed.
|
||||
func buildHTTPCommandsFromTools(srv mcptypes.ServerDescriptor, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
_ = srv
|
||||
_ = tools
|
||||
_ = runner
|
||||
// Dynamic command building from compat.BuildDynamicCommands has been removed.
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildPluginAuthClient creates a transport.Client copy with the plugin's
|
||||
// Bearer token and trusted domains injected. This allows third-party MCP
|
||||
// servers that require independent authentication to be discovered at startup.
|
||||
func buildPluginAuthClient(base *transport.Client, srv mcptypes.ServerDescriptor) *transport.Client {
|
||||
authToken := ""
|
||||
extraHeaders := make(map[string]string)
|
||||
for key, value := range srv.AuthHeaders {
|
||||
if strings.EqualFold(key, "Authorization") {
|
||||
authToken = strings.TrimPrefix(value, "Bearer ")
|
||||
authToken = strings.TrimSpace(authToken)
|
||||
} else {
|
||||
extraHeaders[key] = value
|
||||
}
|
||||
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
|
||||
AppendDynamicServer(srv)
|
||||
if len(srv.AuthHeaders) > 0 {
|
||||
registerPluginAuthFromHeaders(srv)
|
||||
}
|
||||
if authToken == "" {
|
||||
return base
|
||||
}
|
||||
client := base.WithAuth(authToken, extraHeaders)
|
||||
// Trust the endpoint's hostname so the token is actually sent.
|
||||
if parsed, err := url.Parse(srv.Endpoint); err == nil {
|
||||
host := parsed.Hostname()
|
||||
client.TrustedDomains = []string{host, "*." + host}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// registerPluginAuthFromHeaders extracts authentication credentials from
|
||||
@@ -1069,84 +890,6 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
})
|
||||
}
|
||||
|
||||
// registerStdioServer initializes a stdio MCP server, discovers its tools,
|
||||
// and registers the StdioClient for runtime dispatch.
|
||||
func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner executor.Runner, timeouts pluginColdTimeouts) []*cobra.Command {
|
||||
tools := discoverStdioTools(p, sc, timeouts)
|
||||
return buildStdioCommands(p, sc, tools, runner)
|
||||
}
|
||||
|
||||
// discoverStdioTools performs the blocking Initialize + ListTools handshake
|
||||
// on a stdio MCP subprocess. Returns nil on any error (logged at Debug level).
|
||||
// The default 2s budget comfortably accommodates Python/Node runtimes whose
|
||||
// interpreter + dependency load dominates the first response. Operators with
|
||||
// heavier startup chains can relax further via DWS_PLUGIN_COLD_TIMEOUT.
|
||||
//
|
||||
// A handshake failure here is an EXPECTED, benign outcome for an optional local
|
||||
// plugin: e.g. the conference plugin reports "本地服务未就绪" whenever the
|
||||
// DingTalk desktop client isn't running, which is the common case for anyone
|
||||
// not actively recording a meeting. Discovery simply yields no tools and the
|
||||
// run proceeds — commands that ship toolOverrides still register up-front via
|
||||
// registerStdioServerFromOverlay (Phase A), so availability is unaffected.
|
||||
//
|
||||
// These run during command-tree construction (NewRootCommandWithEngine), which
|
||||
// happens BEFORE PersistentPreRunE applies --debug/--verbose via
|
||||
// configureLogLevel. So a Warn here printed to stderr on EVERY invocation
|
||||
// regardless of flags, polluting output and misleading callers into treating it
|
||||
// as the cause of an unrelated command error (e.g. an auth or PARAM_ERROR from a
|
||||
// completely different server). Logging at Debug keeps the discovery miss out of
|
||||
// normal output; surfacing it would require configuring the log level before the
|
||||
// tree is built, which we deliberately avoid this close to release.
|
||||
func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeouts.stdio)
|
||||
defer cancel()
|
||||
|
||||
if _, err := sc.Client.Initialize(ctx); err != nil {
|
||||
slog.Debug("plugin: stdio initialize failed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
toolsResult, err := sc.Client.ListTools(ctx)
|
||||
if err != nil {
|
||||
slog.Debug("plugin: stdio ListTools failed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
return toolsResult.Tools
|
||||
}
|
||||
|
||||
// buildStdioCommands registers the stdio client and server descriptor
|
||||
// for direct runtime dispatch. Dynamic command tree building has been removed.
|
||||
func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []transport.ToolDescriptor, runner executor.Runner) []*cobra.Command {
|
||||
if len(tools) == 0 {
|
||||
slog.Debug("plugin: stdio server has no tools",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
overlay := resolveStdioOverlay(p, sc)
|
||||
|
||||
descriptor := mcptypes.ServerDescriptor{
|
||||
Key: sc.Key,
|
||||
DisplayName: p.Manifest.Name + "/" + sc.Key,
|
||||
Description: p.Manifest.Description,
|
||||
Endpoint: StdioEndpoint(p.Manifest.Name, sc.Key),
|
||||
Source: "plugin",
|
||||
CLI: overlay,
|
||||
HasCLIMeta: true,
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
slog.Debug("plugin: stdio server registered",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
"tools", len(tools))
|
||||
|
||||
_ = runner
|
||||
return nil
|
||||
}
|
||||
|
||||
// newPipelineEngine creates and configures the pipeline engine with
|
||||
// handlers for all five pipeline phases. The phases execute in order:
|
||||
// Register → PreParse → PostParse → PreRequest → PostResponse.
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
@@ -153,9 +154,22 @@ type runtimeRunner struct {
|
||||
scanner safety.Scanner
|
||||
enforceContentScan bool
|
||||
includeScanReport bool
|
||||
auditSink audit.Sink
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
// Global dry-run is an execution barrier, not merely a transport option.
|
||||
// Return a deterministic local preview before profile resolution, catalog
|
||||
// discovery, Keychain/token prefetch, auth, stateful preflight or transport.
|
||||
// Use the non-injectable EchoRunner rather than r.fallback so tests and
|
||||
// edition overlays cannot accidentally turn this path into real execution.
|
||||
if invocation.DryRun || (r != nil && r.globalFlags != nil && r.globalFlags.DryRun) {
|
||||
invocation.DryRun = true
|
||||
return (executor.EchoRunner{}).Run(ctx, invocation)
|
||||
}
|
||||
if r == nil {
|
||||
return executor.Result{}, fmt.Errorf("runtime runner is not configured")
|
||||
}
|
||||
// Emit the one-shot host-owned PAT decision log. Placed here (not in
|
||||
// the constructor) so it fires AFTER PersistentPreRunE has configured
|
||||
// slog level per --debug / --verbose. The Once guard makes repeat
|
||||
@@ -441,6 +455,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return r.executeStdioInvocation(ctx, invocation)
|
||||
}
|
||||
|
||||
// Constructing the Cobra tree is also used for help, schema, and command
|
||||
// discovery. Open the process-wide audit writer only when a real invocation
|
||||
// reaches the execution boundary so read-only command inspection does not
|
||||
// leave an audit lock handle behind (which prevents TempDir cleanup on
|
||||
// Windows). Keep an injected sink when tests or editions provide one.
|
||||
auditSink := r.auditSink
|
||||
if auditSink == nil {
|
||||
auditSink = setupAuditSink()
|
||||
}
|
||||
|
||||
invokeStart := time.Now()
|
||||
execID := generateExecutionID()
|
||||
r.transport.ExecutionId = execID
|
||||
@@ -468,6 +492,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
@@ -704,6 +729,13 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
if err := client.EnsureInitialized(callCtx); err != nil {
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("stdio initialize failed: %v", err),
|
||||
apperrors.WithOperation("initialize"),
|
||||
apperrors.WithReason("stdio_initialize_error"),
|
||||
)
|
||||
}
|
||||
|
||||
callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
var (
|
||||
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
|
||||
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
|
||||
)
|
||||
|
||||
// TestManualAgentExamplesContract is the always-on gate. It validates every
|
||||
// example, including contract_only entries, against the live bound Cobra path,
|
||||
// flags, required arguments, constraints, and final typed safety.
|
||||
func TestManualAgentExamplesContract(t *testing.T) {
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Logf("Agent example contract: total=%d contract=%d dry_run=%d contract_only=%d", plan.Total, plan.Contract, plan.DryRun, plan.ContractOnly)
|
||||
}
|
||||
|
||||
// TestManualAgentExamplesDryRun first validates every reviewed example against
|
||||
// its real BoundCommand, Cobra required arguments, and final typed constraints.
|
||||
// It then executes only the deterministic, explicitly declared dry_run subset
|
||||
// without injecting --yes. Global flag inheritance is not treated as capability
|
||||
// evidence. Runtime failures never create implicit skips. No shell is involved
|
||||
// and HOME is isolated.
|
||||
func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_EXAMPLES_DRY_RUN") != "1" {
|
||||
t.Skip("set DWS_AGENT_EXAMPLES_DRY_RUN=1 to execute the explicitly reviewed Agent dry-run subset")
|
||||
}
|
||||
|
||||
sandboxRoot := t.TempDir()
|
||||
homeDir := filepath.Join(sandboxRoot, "home")
|
||||
configDir := filepath.Join(sandboxRoot, "config")
|
||||
for _, dir := range []string{homeDir, configDir} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("create isolated test directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Chdir(sandboxRoot)
|
||||
files := newManualAgentExampleFiles(t, sandboxRoot)
|
||||
|
||||
selected := 0
|
||||
executed := 0
|
||||
for _, execution := range plan.Examples {
|
||||
if !manualAgentExampleShouldExerciseDryRun(execution) {
|
||||
continue
|
||||
}
|
||||
selected++
|
||||
execution := execution
|
||||
t.Run(fmt.Sprintf("%s/%d", strings.ReplaceAll(execution.CanonicalPath, ".", "/"), execution.Index), func(t *testing.T) {
|
||||
argv, err := cli.ParseManualAgentExampleArgv(execution.Example)
|
||||
if err != nil {
|
||||
t.Fatalf("parse example %q: %v", execution.Example, err)
|
||||
}
|
||||
args := materializeManualAgentExampleArgv(argv[1:], files)
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
t.Fatalf("dry-run gate must not inject or accept --yes\nsource: %s\nargv: %q", execution.Example, args)
|
||||
}
|
||||
if !manualAgentExampleHasFlag(args, "dry-run") {
|
||||
args = append([]string{"--dry-run"}, args...)
|
||||
}
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("eligible dry-run attempted %d ToolCaller invocation(s)\nsource: %s\nargv: %q\noutput:\n%s", capture.ToolCallAttempts, execution.Example, args, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("eligible dry-run entered an interactive confirmation path (stdin bytes read: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.StdinBytesRead, execution.Example, args, capture.Output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run example failed: %v\nsource: %s\nargv: %q\noutput:\n%s", err, execution.Example, args, capture.Output)
|
||||
}
|
||||
previewKind, observed := manualAgentExampleDryRunEvidence(capture)
|
||||
if !observed {
|
||||
t.Fatalf("example returned without audited dry-run evidence (caller dry-run checks: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.DryRunChecks, execution.Example, args, capture.Output)
|
||||
}
|
||||
if want := execution.DryRun.PreviewKind; previewKind != want {
|
||||
t.Fatalf("dry-run preview kind = %q, Schema declares %q\nsource: %s\nargv: %q\noutput:\n%s", previewKind, want, execution.Example, args, capture.Output)
|
||||
}
|
||||
t.Logf("dry_run_capability_candidate=%s", previewKind)
|
||||
executed++
|
||||
})
|
||||
}
|
||||
if executed != selected {
|
||||
t.Fatalf("executed dry_run examples = %d, selected capability set requires %d", executed, selected)
|
||||
}
|
||||
t.Logf("Agent examples: total=%d contract=%d dry_run_selected=%d planned_dry_run=%d contract_only=%d reviewed_manual=%d", plan.Total, plan.Contract, selected, plan.DryRun, plan.ContractOnly, plan.ReviewedContractOnly)
|
||||
reasonCodes := make([]string, 0, len(plan.ContractOnlyByReason))
|
||||
for reasonCode := range plan.ContractOnlyByReason {
|
||||
reasonCodes = append(reasonCodes, string(reasonCode))
|
||||
}
|
||||
sort.Strings(reasonCodes)
|
||||
for _, reasonCode := range reasonCodes {
|
||||
t.Logf("Agent examples contract_only[%s]=%d", reasonCode, plan.ContractOnlyByReason[cli.ManualAgentExampleReasonCode(reasonCode)])
|
||||
}
|
||||
}
|
||||
|
||||
// manualAgentExampleShouldExerciseDryRun is the single selection boundary for
|
||||
// the runtime gate. Capability comes only from the final typed ToolSpec; the
|
||||
// example disposition may narrow that set but can never invent support.
|
||||
func manualAgentExampleShouldExerciseDryRun(execution cli.ManualAgentExampleExecution) bool {
|
||||
return execution.DryRun != nil && execution.Mode == cli.ManualAgentExampleModeDryRun
|
||||
}
|
||||
|
||||
func manualAgentExampleExecutionPlan(t testing.TB) cli.ManualAgentExampleExecutionPlan {
|
||||
t.Helper()
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
contractRoot := NewRootCommand()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(contractRoot); err != nil {
|
||||
t.Fatalf("ApplyEmbeddedManualSchemaHints() error = %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(contractRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(contractRoot, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
registry, err := cli.AssembleSchemaRegistryFromBound(bound)
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleSchemaRegistryFromBound() error = %v", err)
|
||||
}
|
||||
if err := cli.ValidateReviewedDryRunCapabilityDelivery(registry); err != nil {
|
||||
t.Fatalf("ValidateReviewedDryRunCapabilityDelivery() error = %v", err)
|
||||
}
|
||||
plan, err := cli.BuildManualAgentExampleExecutionPlan(bound, registry, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentExampleExecutionPlan() error = %v", err)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
type manualAgentExampleCapture struct {
|
||||
Output string
|
||||
DryRunChecks int64
|
||||
ToolCallAttempts int64
|
||||
StdinBytesRead int64
|
||||
}
|
||||
|
||||
type manualAgentExampleFailClosedCaller struct {
|
||||
dryRunChecks atomic.Int64
|
||||
toolCallAttempts atomic.Int64
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.toolCallAttempts.Add(1)
|
||||
return nil, fmt.Errorf("real ToolCaller invocation blocked during Agent example dry-run: %s/%s", productID, toolName)
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Format() string { return "json" }
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) DryRun() bool {
|
||||
c.dryRunChecks.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Fields() string { return "" }
|
||||
func (c *manualAgentExampleFailClosedCaller) JQ() string { return "" }
|
||||
|
||||
func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentExampleCapture, error) {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
defer func() { os.Args = oldArgs }()
|
||||
oldStdin := os.Stdin
|
||||
promptInput, err := os.CreateTemp(t.TempDir(), "agent-example-stdin-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("open guarded stdin: %v", err)
|
||||
}
|
||||
defer promptInput.Close()
|
||||
if _, err := promptInput.WriteString("no\n"); err != nil {
|
||||
t.Fatalf("seed guarded stdin: %v", err)
|
||||
}
|
||||
if _, err := promptInput.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind guarded stdin: %v", err)
|
||||
}
|
||||
os.Stdin = promptInput
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
oldStdout, oldStderr := os.Stdout, os.Stderr
|
||||
oldColorOutput, oldColorError := color.Output, color.Error
|
||||
captureFile, err := os.CreateTemp(t.TempDir(), "agent-example-output-*.log")
|
||||
if err != nil {
|
||||
t.Fatalf("open output capture file: %v", err)
|
||||
}
|
||||
defer captureFile.Close()
|
||||
os.Stdout, os.Stderr = captureFile, captureFile
|
||||
color.Output, color.Error = captureFile, captureFile
|
||||
defer func() {
|
||||
os.Stdout, os.Stderr = oldStdout, oldStderr
|
||||
color.Output, color.Error = oldColorOutput, oldColorError
|
||||
}()
|
||||
|
||||
root := NewRootCommand()
|
||||
originalCaller := helpers.GetCaller()
|
||||
auditCaller := &manualAgentExampleFailClosedCaller{}
|
||||
helpers.InitDeps(auditCaller)
|
||||
defer helpers.InitDeps(originalCaller)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetErr(&output)
|
||||
root.SetArgs(args)
|
||||
execErr := root.Execute()
|
||||
|
||||
os.Stdout, os.Stderr = oldStdout, oldStderr
|
||||
color.Output, color.Error = oldColorOutput, oldColorError
|
||||
if _, err := captureFile.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind output capture file: %v", err)
|
||||
}
|
||||
captured, readErr := io.ReadAll(captureFile)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output capture file: %v", readErr)
|
||||
}
|
||||
stdinBytesRead, err := promptInput.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect guarded stdin: %v", err)
|
||||
}
|
||||
return manualAgentExampleCapture{
|
||||
Output: output.String() + string(captured),
|
||||
DryRunChecks: auditCaller.dryRunChecks.Load(),
|
||||
ToolCallAttempts: auditCaller.toolCallAttempts.Load(),
|
||||
StdinBytesRead: stdinBytesRead,
|
||||
}, execErr
|
||||
}
|
||||
|
||||
type manualAgentExampleFiles struct {
|
||||
root string
|
||||
markdown string
|
||||
json string
|
||||
batch string
|
||||
binary string
|
||||
image string
|
||||
}
|
||||
|
||||
func newManualAgentExampleFiles(t testing.TB, root string) manualAgentExampleFiles {
|
||||
t.Helper()
|
||||
markdown := filepath.Join(root, "content.md")
|
||||
jsonFile := filepath.Join(root, "report.json")
|
||||
batch := filepath.Join(root, "styles.json")
|
||||
binary := filepath.Join(root, "report.pdf")
|
||||
image := filepath.Join(root, "chart.png")
|
||||
for path, content := range map[string][]byte{
|
||||
markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"),
|
||||
jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`),
|
||||
batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`),
|
||||
binary: []byte("%PDF-1.4\n%%EOF\n"),
|
||||
image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'},
|
||||
} {
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return manualAgentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
}
|
||||
|
||||
func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFiles) []string {
|
||||
result := append([]string(nil), argv...)
|
||||
for index := range result {
|
||||
result[index] = manualAgentExamplePlaceholderPattern.ReplaceAllStringFunc(result[index], func(match string) string {
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(match, "<"), ">")
|
||||
switch strings.ToLower(name) {
|
||||
case "basetime", "remindertimestamp", "reminder-time-stamp":
|
||||
return "1780000000000"
|
||||
case "duedateoffset", "due-date-offset":
|
||||
return "0"
|
||||
case "reminderrules", "reminder-rules":
|
||||
return `[{"remindType":"minute","remindTime":10}]`
|
||||
case "filepath", "file-path":
|
||||
return files.binary
|
||||
case "uuid1,uuid2":
|
||||
return "uuid1,uuid2"
|
||||
default:
|
||||
clean := strings.NewReplacer(",", "_", "-", "_", ".", "_").Replace(name)
|
||||
return "test_" + clean
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for index := 0; index < len(result); index++ {
|
||||
name, inline, ok := manualAgentExampleLongFlag(result[index])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
valueIndex := index + 1
|
||||
value := inline
|
||||
if inline == "" && valueIndex < len(result) {
|
||||
value = result[valueIndex]
|
||||
}
|
||||
replacement := ""
|
||||
switch name {
|
||||
case "file", "file-path":
|
||||
if strings.Contains(strings.ToLower(value), "png") {
|
||||
replacement = files.image
|
||||
} else {
|
||||
replacement = files.binary
|
||||
}
|
||||
case "content-file":
|
||||
replacement = files.markdown
|
||||
case "contents-file":
|
||||
replacement = files.json
|
||||
case "batch":
|
||||
if strings.HasSuffix(strings.ToLower(value), "styles.json") {
|
||||
replacement = files.batch
|
||||
}
|
||||
case "output":
|
||||
if value == "." || value == "" {
|
||||
replacement = files.root
|
||||
} else {
|
||||
replacement = filepath.Join(files.root, filepath.Base(value))
|
||||
}
|
||||
}
|
||||
if replacement == "" {
|
||||
continue
|
||||
}
|
||||
if inline != "" {
|
||||
result[index] = "--" + name + "=" + replacement
|
||||
} else if valueIndex < len(result) {
|
||||
result[valueIndex] = replacement
|
||||
index++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func manualAgentExampleLongFlag(argument string) (name, inline string, ok bool) {
|
||||
if !strings.HasPrefix(argument, "--") {
|
||||
return "", "", false
|
||||
}
|
||||
name, inline, _ = strings.Cut(strings.TrimPrefix(argument, "--"), "=")
|
||||
return name, inline, name != ""
|
||||
}
|
||||
|
||||
func manualAgentExampleHasFlag(argv []string, target string) bool {
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+target || strings.HasPrefix(argument, "--"+target+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool {
|
||||
_, ok := manualAgentExampleDryRunEvidence(capture)
|
||||
return ok
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) {
|
||||
normalized := strings.ToLower(capture.Output)
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewRequest, true
|
||||
}
|
||||
if strings.Contains(normalized, "[dry-run]") {
|
||||
return cli.DryRunPreviewInvocation, true
|
||||
}
|
||||
if capture.DryRunChecks > 0 && strings.Contains(capture.Output, "操作:") {
|
||||
return cli.DryRunPreviewPlan, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing.T) {
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "[DRY-RUN] Preview only, not executed:\nTool: calendar_list"}) {
|
||||
t.Fatal("dry-run output with a Tool and nil Arguments was not recognized")
|
||||
}
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "Tool: calendar_list"}) {
|
||||
t.Fatal("a Tool line without dry-run evidence must not be accepted")
|
||||
}
|
||||
for _, falseEvidence := range []string{
|
||||
"unknown flag: --dry-run",
|
||||
"Run again with --dry-run to preview the operation",
|
||||
`{"dry_run":false,"executed":true}`,
|
||||
} {
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: falseEvidence}) {
|
||||
t.Errorf("non-evidence text was mistaken for a successful dry-run: %q", falseEvidence)
|
||||
}
|
||||
}
|
||||
operationSummary := "操作: 下载钉盘文件\n文件ID: test"
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary}) {
|
||||
t.Fatal("a human-only operation summary without an audited dry-run check must not be accepted")
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary, DryRunChecks: 1}) {
|
||||
t.Fatal("a command plan guarded by the injected caller's dry-run check was not recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func manualAgentExamplePromptObserved(output string) bool {
|
||||
normalized := strings.ToLower(output)
|
||||
for _, marker := range []string{
|
||||
"confirm ",
|
||||
"confirm deletion?",
|
||||
"confirm action?",
|
||||
"confirm create?",
|
||||
"confirm update?",
|
||||
"confirm save?",
|
||||
"confirm import?",
|
||||
"are you sure",
|
||||
"operation cancelled",
|
||||
"操作已取消",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testing.T) {
|
||||
for _, prompt := range []string{
|
||||
"Confirm deletion? (yes/no):",
|
||||
"Confirm action? (yes/no):",
|
||||
"Confirm create? (yes/no):",
|
||||
"Confirm update? (yes/no):",
|
||||
"Confirm save? (yes/no):",
|
||||
"Confirm import? (yes/no):",
|
||||
"Are you sure you want to continue?",
|
||||
"Operation cancelled",
|
||||
} {
|
||||
if !manualAgentExamplePromptObserved(prompt) {
|
||||
t.Errorf("interactive confirmation output was not detected: %q", prompt)
|
||||
}
|
||||
}
|
||||
if manualAgentExamplePromptObserved(`{"dry_run":true,"confirmation":"user_required"}`) {
|
||||
t.Fatal("typed safety metadata was mistaken for an interactive prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableAdvpermDisableDryRunSkipsConfirmationAndToolCall(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
args := []string{
|
||||
"--dry-run", "--format", "json",
|
||||
"aitable", "advperm", "disable",
|
||||
"--base-id", "BASE_ID",
|
||||
}
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
t.Fatal("regression test must not bypass confirmation with --yes")
|
||||
}
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
if err != nil {
|
||||
t.Fatalf("advperm disable fail-closed dry-run failed: %v\noutput:\n%s", err, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("advperm disable dry-run entered confirmation (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("advperm disable dry-run attempted %d real ToolCaller invocation(s)\noutput:\n%s", capture.ToolCallAttempts, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("advperm disable returned no audited dry-run evidence (caller dry-run checks: %d)\noutput:\n%s", capture.DryRunChecks, capture.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
caller := &manualAgentExampleFailClosedCaller{}
|
||||
if !caller.DryRun() {
|
||||
t.Fatal("fail-closed caller must advertise dry-run mode")
|
||||
}
|
||||
if _, err := caller.CallTool(context.Background(), "calendar", "list_events", nil); err == nil {
|
||||
t.Fatal("fail-closed caller accepted a ToolCaller invocation")
|
||||
}
|
||||
if got := caller.dryRunChecks.Load(); got != 1 {
|
||||
t.Fatalf("DryRun() checks = %d, want 1", got)
|
||||
}
|
||||
if got := caller.toolCallAttempts.Load(); got != 1 {
|
||||
t.Fatalf("CallTool() attempts = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testing.T) {
|
||||
sandboxRoot := t.TempDir()
|
||||
configDir := filepath.Join(sandboxRoot, "config")
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatalf("create isolated config directory: %v", err)
|
||||
}
|
||||
t.Setenv("HOME", sandboxRoot)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, []string{
|
||||
"--dry-run",
|
||||
"chat", "group-mute-member",
|
||||
"--group", "test_openConversationId",
|
||||
"--users", "userId1,userId2",
|
||||
"--mute-time", "3600000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("group-mute-member dry-run failed: %v\noutput:\n%s", err, capture.Output)
|
||||
}
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("group-mute-member dry-run attempted %d ToolCaller invocation(s)\noutput:\n%s", capture.ToolCallAttempts, capture.Output)
|
||||
}
|
||||
if capture.DryRunChecks == 0 {
|
||||
t.Fatalf("group-mute-member did not enter its audited command dry-run path\noutput:\n%s", capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("group-mute-member dry-run entered an interactive prompt (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("group-mute-member returned no audited dry-run evidence\noutput:\n%s", capture.Output)
|
||||
}
|
||||
for _, expected := range []string{`"uids"`, `"userId1"`, `"userId2"`} {
|
||||
if !strings.Contains(capture.Output, expected) {
|
||||
t.Fatalf("group-mute-member command preview missing %s\noutput:\n%s", expected, capture.Output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(capture.Output, `"openDingTalkIds"`) {
|
||||
t.Fatalf("group-mute-member dry-run unexpectedly resolved user IDs remotely\noutput:\n%s", capture.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
func TestManualAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
|
||||
fixture, report, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
if report.Tools != len(bound.Commands) {
|
||||
t.Fatalf("selection tools = %d, bound commands = %d", report.Tools, len(bound.Commands))
|
||||
}
|
||||
if report.PositiveAssertions < report.Tools {
|
||||
t.Fatalf("positive selection coverage = %+v, want at least one assertion per tool", report)
|
||||
}
|
||||
if report.NegativeAssertions < report.Tools {
|
||||
t.Fatalf("negative selection coverage = %+v, want at least one assertion per tool", report)
|
||||
}
|
||||
if report.Tools == 0 {
|
||||
t.Fatal("selection contract unexpectedly contains no tools")
|
||||
}
|
||||
if len(fixture.Cases) != report.PositiveAssertions+report.NegativeAssertions {
|
||||
t.Fatalf("selection fixture cases = %d, report = %+v", len(fixture.Cases), report)
|
||||
}
|
||||
if report.FixtureSHA256 == "" {
|
||||
t.Fatal("selection fixture digest is empty")
|
||||
}
|
||||
t.Logf("validated %d bound tools, %d positive assertions, %d negative assertions (%s)", report.Tools, report.PositiveAssertions, report.NegativeAssertions, report.FixtureSHA256)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const manualAgentSelectionLiveBatchSize = 12
|
||||
|
||||
type manualAgentSelectionLiveCandidate struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
AgentSummary string `json:"agent_summary"`
|
||||
UseWhen []string `json:"use_when"`
|
||||
AvoidWhen []string `json:"avoid_when"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveInput struct {
|
||||
Cases []manualAgentSelectionLiveCase `json:"cases"`
|
||||
Candidates []manualAgentSelectionLiveCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// manualAgentSelectionLiveCase is deliberately answer-free. Expected and
|
||||
// forbidden canonicals stay only in the local assertion fixture and are never
|
||||
// sent to the model being evaluated.
|
||||
type manualAgentSelectionLiveCase struct {
|
||||
ID string `json:"id"`
|
||||
Scenario string `json:"scenario"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResult struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResponse struct {
|
||||
Results []manualAgentSelectionLiveResult `json:"results"`
|
||||
}
|
||||
|
||||
// TestManualAgentSelectionArkLive is intentionally opt-in. Deterministic CI
|
||||
// validates all fixture and Cobra facts without network access; this test asks
|
||||
// a real model to interpret the reviewed natural-language scenarios. Set
|
||||
// DWS_AGENT_SELECTION_FULL=1 to evaluate every positive and negative case.
|
||||
func TestManualAgentSelectionArkLive(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_SELECTION_LIVE") != "1" {
|
||||
t.Skip("set DWS_AGENT_SELECTION_LIVE=1 and ARK_API_KEY/ARK_BASE_URL/ARK_MODEL to run live Agent command-selection evaluation")
|
||||
}
|
||||
apiKey := strings.TrimSpace(os.Getenv("ARK_API_KEY"))
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("ARK_BASE_URL")), "/")
|
||||
model := strings.TrimSpace(os.Getenv("ARK_MODEL"))
|
||||
for name, value := range map[string]string{
|
||||
"ARK_API_KEY": apiKey,
|
||||
"ARK_BASE_URL": baseURL,
|
||||
"ARK_MODEL": model,
|
||||
} {
|
||||
if value == "" {
|
||||
t.Fatalf("%s is required when DWS_AGENT_SELECTION_LIVE=1", name)
|
||||
}
|
||||
}
|
||||
if err := validateManualAgentSelectionLiveBaseURL(baseURL, os.Getenv("DWS_AGENT_SELECTION_ALLOWED_BASE_URLS")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fixture, hints := manualAgentSelectionLiveFixture(t)
|
||||
cases := selectManualAgentSelectionLiveCases(t, fixture.Cases)
|
||||
for _, batch := range batchManualAgentSelectionLiveCases(cases, manualAgentSelectionLiveBatchSize) {
|
||||
productID := batch[0].ProductID
|
||||
t.Run(productID+"/"+sanitizeManualAgentSelectionLiveTestID(batch[0].ID), func(t *testing.T) {
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
results := callManualAgentSelectionLiveModel(t, baseURL, apiKey, model, input)
|
||||
assertManualAgentSelectionLiveResults(t, batch, results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildManualAgentSelectionLiveInput(batch []cli.ManualAgentSelectionCase, hints cli.ManualAgentHintSet) manualAgentSelectionLiveInput {
|
||||
input := manualAgentSelectionLiveInput{Cases: make([]manualAgentSelectionLiveCase, 0, len(batch))}
|
||||
if len(batch) == 0 {
|
||||
return input
|
||||
}
|
||||
for _, selectionCase := range batch {
|
||||
input.Cases = append(input.Cases, manualAgentSelectionLiveCase{
|
||||
ID: selectionCase.ID,
|
||||
Scenario: selectionCase.Scenario,
|
||||
})
|
||||
}
|
||||
input.Candidates = make([]manualAgentSelectionLiveCandidate, 0, len(batch[0].CandidateCanonicals))
|
||||
for _, canonical := range batch[0].CandidateCanonicals {
|
||||
hint := hints.Tools[canonical]
|
||||
input.Candidates = append(input.Candidates, manualAgentSelectionLiveCandidate{
|
||||
CanonicalPath: canonical,
|
||||
AgentSummary: hint.AgentSummary,
|
||||
UseWhen: hint.UseWhen,
|
||||
AvoidWhen: hint.AvoidWhen,
|
||||
})
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func manualAgentSelectionLiveFixture(t testing.TB) (cli.ManualAgentSelectionFixture, cli.ManualAgentHintSet) {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
fixture, _, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
return fixture, hints
|
||||
}
|
||||
|
||||
func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSelectionCase) []cli.ManualAgentSelectionCase {
|
||||
t.Helper()
|
||||
if raw := strings.TrimSpace(os.Getenv("DWS_AGENT_SELECTION_CASES")); raw != "" {
|
||||
selected := map[string]bool{}
|
||||
for _, id := range strings.Split(raw, ",") {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
selected[id] = true
|
||||
}
|
||||
}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0, len(selected))
|
||||
for _, selectionCase := range cases {
|
||||
if selected[selectionCase.ID] {
|
||||
result = append(result, selectionCase)
|
||||
delete(selected, selectionCase.ID)
|
||||
}
|
||||
}
|
||||
if len(selected) != 0 {
|
||||
missing := make([]string, 0, len(selected))
|
||||
for id := range selected {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
sort.Strings(missing)
|
||||
t.Fatalf("DWS_AGENT_SELECTION_CASES contains unknown case IDs: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return result
|
||||
}
|
||||
if os.Getenv("DWS_AGENT_SELECTION_FULL") == "1" {
|
||||
return append([]cli.ManualAgentSelectionCase(nil), cases...)
|
||||
}
|
||||
|
||||
// Smoke mode exercises one positive and one negative scenario per product.
|
||||
seenPositive := map[string]bool{}
|
||||
seenNegative := map[string]bool{}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if selectionCase.ExpectedCanonical != "" && !seenPositive[selectionCase.ProductID] {
|
||||
seenPositive[selectionCase.ProductID] = true
|
||||
result = append(result, selectionCase)
|
||||
}
|
||||
if selectionCase.ForbiddenCanonical != "" && !seenNegative[selectionCase.ProductID] {
|
||||
seenNegative[selectionCase.ProductID] = true
|
||||
result = append(result, selectionCase)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, batchSize int) [][]cli.ManualAgentSelectionCase {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
grouped := map[string][]cli.ManualAgentSelectionCase{}
|
||||
products := make([]string, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if _, ok := grouped[selectionCase.ProductID]; !ok {
|
||||
products = append(products, selectionCase.ProductID)
|
||||
}
|
||||
grouped[selectionCase.ProductID] = append(grouped[selectionCase.ProductID], selectionCase)
|
||||
}
|
||||
sort.Strings(products)
|
||||
result := make([][]cli.ManualAgentSelectionCase, 0)
|
||||
for _, productID := range products {
|
||||
productCases := grouped[productID]
|
||||
for start := 0; start < len(productCases); start += batchSize {
|
||||
end := start + batchSize
|
||||
if end > len(productCases) {
|
||||
end = len(productCases)
|
||||
}
|
||||
result = append(result, append([]cli.ManualAgentSelectionCase(nil), productCases[start:end]...))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model string, input manualAgentSelectionLiveInput) []manualAgentSelectionLiveResult {
|
||||
t.Helper()
|
||||
body, err := marshalManualAgentSelectionLiveRequest(baseURL, model, input)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal live selection request: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build live selection request: %v", err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) > 0 && (request.URL.Scheme != via[0].URL.Scheme || !strings.EqualFold(request.URL.Host, via[0].URL.Host)) {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("live selection model request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
detail, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
t.Fatalf("live selection model status %s: %s", response.Status, strings.TrimSpace(string(detail)))
|
||||
}
|
||||
var envelope struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 2<<20)).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode live selection response envelope: %v", err)
|
||||
}
|
||||
if len(envelope.Choices) == 0 || strings.TrimSpace(envelope.Choices[0].Message.Content) == "" {
|
||||
t.Fatal("live selection response has no model content")
|
||||
}
|
||||
var selection manualAgentSelectionLiveResponse
|
||||
decoder := json.NewDecoder(strings.NewReader(envelope.Choices[0].Message.Content))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&selection); err != nil {
|
||||
t.Fatalf("decode live selection model JSON: %v; content=%s", err, envelope.Choices[0].Message.Content)
|
||||
}
|
||||
return selection.Results
|
||||
}
|
||||
|
||||
func marshalManualAgentSelectionLiveRequest(baseURL, model string, input manualAgentSelectionLiveInput) ([]byte, error) {
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal live selection input: %w", err)
|
||||
}
|
||||
requestBody := map[string]any{
|
||||
"model": model,
|
||||
"temperature": 0,
|
||||
"max_tokens": 4096,
|
||||
"messages": []map[string]string{
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You evaluate DWS Agent command selection. For each case, interpret the natural-language scenario and choose exactly one canonical_path from candidates, or the literal string none when no candidate is appropriate. Return only JSON as {\"results\":[{\"id\":\"case id\",\"canonical_path\":\"candidate or none\"}]}. Return every case ID exactly once. Do not execute commands.",
|
||||
},
|
||||
{"role": "user", "content": string(inputJSON)},
|
||||
},
|
||||
}
|
||||
// Ark plan endpoints do not consistently accept response_format. Other
|
||||
// OpenAI-compatible endpoints get the stricter JSON-object request.
|
||||
if !strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api/plan/v3") {
|
||||
requestBody["response_format"] = map[string]string{"type": "json_object"}
|
||||
}
|
||||
return json.Marshal(requestBody)
|
||||
}
|
||||
|
||||
func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgentSelectionCase, results []manualAgentSelectionLiveResult) {
|
||||
t.Helper()
|
||||
byID := make(map[string]manualAgentSelectionLiveResult, len(results))
|
||||
for _, result := range results {
|
||||
if _, exists := byID[result.ID]; exists {
|
||||
t.Fatalf("live selection returned duplicate case ID %q", result.ID)
|
||||
}
|
||||
byID[result.ID] = result
|
||||
}
|
||||
for _, selectionCase := range cases {
|
||||
result, ok := byID[selectionCase.ID]
|
||||
if !ok {
|
||||
t.Errorf("live selection omitted case %q", selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
delete(byID, selectionCase.ID)
|
||||
selected := strings.TrimSpace(result.CanonicalPath)
|
||||
if selected == "" {
|
||||
t.Errorf("live selection returned empty canonical for %q", selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
if selected != "none" && !containsManualAgentSelectionCanonical(selectionCase.CandidateCanonicals, selected) {
|
||||
t.Errorf("live selection returned non-candidate %q for %q", selected, selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
if selectionCase.ExpectedCanonical != "" && selected != selectionCase.ExpectedCanonical {
|
||||
t.Errorf("live positive selection %q = %q, want %q; scenario=%q", selectionCase.ID, selected, selectionCase.ExpectedCanonical, selectionCase.Scenario)
|
||||
}
|
||||
if selectionCase.ForbiddenCanonical != "" && selected == selectionCase.ForbiddenCanonical {
|
||||
t.Errorf("live negative selection %q chose forbidden %q; scenario=%q", selectionCase.ID, selected, selectionCase.Scenario)
|
||||
}
|
||||
}
|
||||
if len(byID) != 0 {
|
||||
unexpected := make([]string, 0, len(byID))
|
||||
for id := range byID {
|
||||
unexpected = append(unexpected, id)
|
||||
}
|
||||
sort.Strings(unexpected)
|
||||
t.Errorf("live selection returned unexpected case IDs: %s", strings.Join(unexpected, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func validateManualAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||||
return fmt.Errorf("ARK_BASE_URL must be an absolute HTTP(S) API base without query or fragment")
|
||||
}
|
||||
if parsed.Scheme == "http" {
|
||||
if !manualAgentSelectionLoopbackHost(parsed.Hostname()) {
|
||||
return fmt.Errorf("ARK_BASE_URL may use plaintext HTTP only for a loopback test endpoint")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("ARK_BASE_URL must use HTTPS, except for a loopback HTTP test endpoint")
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
"https://ark.ap-southeast.bytepluses.com/api/v3": true,
|
||||
"https://ark.cn-beijing.volces.com/api/plan/v3": true,
|
||||
}
|
||||
for _, candidate := range strings.Split(extraAllowed, ",") {
|
||||
candidate = strings.TrimRight(strings.TrimSpace(candidate), "/")
|
||||
if candidate != "" {
|
||||
allowed[candidate] = true
|
||||
}
|
||||
}
|
||||
normalized := strings.TrimRight(raw, "/")
|
||||
if !allowed[normalized] {
|
||||
return fmt.Errorf("ARK_BASE_URL %q is not allowlisted; use a built-in Ark base or add the exact HTTPS base to DWS_AGENT_SELECTION_ALLOWED_BASE_URLS", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func manualAgentSelectionLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func containsManualAgentSelectionCanonical(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeManualAgentSelectionLiveTestID(value string) string {
|
||||
value = strings.ReplaceAll(value, ".", "_")
|
||||
value = strings.ReplaceAll(value, "/", "_")
|
||||
return value
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveResultContract(t *testing.T) {
|
||||
cases := []cli.ManualAgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
t.Run("accepts exact positive and negative choices", func(t *testing.T) {
|
||||
assertManualAgentSelectionLiveResults(t, cases, []manualAgentSelectionLiveResult{
|
||||
{ID: cases[0].ID, CanonicalPath: "sample.search"},
|
||||
{ID: cases[1].ID, CanonicalPath: "sample.create"},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t *testing.T) {
|
||||
batch := []cli.ManualAgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
hints := cli.ManualAgentHintSet{Tools: map[string]cli.ManualAgentToolHint{
|
||||
"sample.create": {AgentSummary: "Create an item", UseWhen: []string{"create"}, AvoidWhen: []string{"find"}},
|
||||
"sample.search": {AgentSummary: "Search items", UseWhen: []string{"find"}, AvoidWhen: []string{"create"}},
|
||||
}}
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
data, err := marshalManualAgentSelectionLiveRequest("https://ark.cn-beijing.volces.com/api/plan/v3", "fixed-model", input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbiddenKey := range []string{"expected_canonical", "forbidden_canonical", "candidate_canonicals"} {
|
||||
if strings.Contains(string(data), forbiddenKey) {
|
||||
t.Fatalf("live model payload leaks local assertion field %q: %s", forbiddenKey, data)
|
||||
}
|
||||
}
|
||||
if len(input.Candidates) != 2 || len(input.Cases) != 2 {
|
||||
t.Fatalf("live model input = %+v", input)
|
||||
}
|
||||
if count := strings.Count(string(data), `\"candidates\"`); count != 1 {
|
||||
t.Fatalf("live request contains candidate table %d times, want once: %s", count, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
extraAllowed string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "built-in Ark", baseURL: "https://ark.cn-beijing.volces.com/api/plan/v3"},
|
||||
{name: "loopback localhost", baseURL: "http://localhost:8080/v1"},
|
||||
{name: "loopback IPv4", baseURL: "http://127.0.0.1:8080/v1"},
|
||||
{name: "loopback IPv6", baseURL: "http://[::1]:8080/v1"},
|
||||
{name: "allowlisted HTTPS extension", baseURL: "https://models.example.test/v1", extraAllowed: "https://models.example.test/v1"},
|
||||
{name: "plaintext remote", baseURL: "http://models.example.test/v1", wantErr: "only for a loopback"},
|
||||
{name: "HTTPS not allowlisted", baseURL: "https://models.example.test/v1", wantErr: "not allowlisted"},
|
||||
{name: "allowlist path mismatch", baseURL: "https://models.example.test/v2", extraAllowed: "https://models.example.test/v1", wantErr: "not allowlisted"},
|
||||
{name: "URL credentials", baseURL: "https://token@ark.cn-beijing.volces.com/api/plan/v3", wantErr: "absolute HTTP(S)"},
|
||||
{name: "query", baseURL: "https://ark.cn-beijing.volces.com/api/plan/v3?q=1", wantErr: "absolute HTTP(S)"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateManualAgentSelectionLiveBaseURL(test.baseURL, test.extraAllowed)
|
||||
if test.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate base URL: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestFinalSchemaToolsHaveExecutableBaseCommands is the final Schema-to-Cobra
|
||||
// delivery gate. It starts from the reviewed CommandRegistry and live Cobra
|
||||
// tree, then verifies the complete final Schema projection against the bound
|
||||
// commands. The Catalog is observed only as a delivery output; it is never
|
||||
// used to discover or synthesize a command identity.
|
||||
func TestFinalSchemaToolsHaveExecutableBaseCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("bind EffectiveCommandRegistry to live Cobra tree: %v", err)
|
||||
}
|
||||
|
||||
publicCanonicals := make([]string, 0, len(bound.Commands))
|
||||
for _, command := range bound.Commands {
|
||||
if command.Visibility == cli.SchemaVisibilityPublic {
|
||||
publicCanonicals = append(publicCanonicals, command.CanonicalPath)
|
||||
}
|
||||
}
|
||||
sort.Strings(publicCanonicals)
|
||||
finalCanonicals := make([]string, 0, len(snapshot.Tools))
|
||||
for canonical := range snapshot.Tools {
|
||||
finalCanonicals = append(finalCanonicals, canonical)
|
||||
}
|
||||
sort.Strings(finalCanonicals)
|
||||
if diff := schemaBaseCommandSetDiff(publicCanonicals, finalCanonicals); diff != "" {
|
||||
t.Fatalf("final Schema tool set differs from public BoundCommandRegistry: %s", diff)
|
||||
}
|
||||
|
||||
for _, canonical := range finalCanonicals {
|
||||
canonical := canonical
|
||||
t.Run(canonical, func(t *testing.T) {
|
||||
tool := snapshot.Tools[canonical]
|
||||
command, ok := bound.ByCanonical[canonical]
|
||||
if !ok {
|
||||
t.Fatalf("final Schema tool has no BoundCommand")
|
||||
}
|
||||
if command.Visibility != cli.SchemaVisibilityPublic {
|
||||
t.Fatalf("final Schema tool binds non-public command visibility %q", command.Visibility)
|
||||
}
|
||||
if got := schemaBaseCommandString(tool["canonical_path"]); got != canonical {
|
||||
t.Fatalf("final canonical_path = %q, want %q", got, canonical)
|
||||
}
|
||||
if got := schemaBaseCommandString(tool["primary_cli_path"]); got != command.PrimaryCLIPath {
|
||||
t.Fatalf("final primary_cli_path = %q, want bound primary %q", got, command.PrimaryCLIPath)
|
||||
}
|
||||
if got := schemaBaseCommandString(tool["cli_path"]); got != command.PrimaryCLIPath {
|
||||
t.Fatalf("final canonical view cli_path = %q, want bound primary %q", got, command.PrimaryCLIPath)
|
||||
}
|
||||
|
||||
primaryMatch, err := resolveSchemaBaseCommandPath(root, command.PrimaryCLIPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve primary path exactly: %v", err)
|
||||
}
|
||||
if primaryMatch.command == nil {
|
||||
t.Fatalf("bound primary path %q does not exist in live Cobra tree", command.PrimaryCLIPath)
|
||||
}
|
||||
if primaryMatch.usedAlias {
|
||||
t.Fatalf("bound primary path %q resolves through Cobra Aliases", command.PrimaryCLIPath)
|
||||
}
|
||||
if primaryMatch.command != command.PrimaryCommand {
|
||||
t.Fatalf("bound primary pointer differs from exact live Cobra path %q", command.PrimaryCLIPath)
|
||||
}
|
||||
assertRunnableSchemaBaseCommand(t, command.PrimaryCommand, command.PrimaryCLIPath)
|
||||
|
||||
// Cobra dispatches a parsed --help flag to Help without invoking the
|
||||
// command's Run/RunE or any business interface. Calling Help directly
|
||||
// therefore exercises the same renderer without network side effects.
|
||||
var help bytes.Buffer
|
||||
command.PrimaryCommand.SetOut(&help)
|
||||
command.PrimaryCommand.SetErr(&help)
|
||||
if err := command.PrimaryCommand.Help(); err != nil {
|
||||
t.Fatalf("render %q --help: %v", command.PrimaryCLIPath, err)
|
||||
}
|
||||
if strings.TrimSpace(help.String()) == "" {
|
||||
t.Fatalf("%q --help rendered an empty document", command.PrimaryCLIPath)
|
||||
}
|
||||
|
||||
wantAliases := append([]string(nil), command.Aliases...)
|
||||
gotAliases := schemaBaseCommandStringSlice(tool["aliases"])
|
||||
sort.Strings(wantAliases)
|
||||
sort.Strings(gotAliases)
|
||||
if diff := schemaBaseCommandSetDiff(wantAliases, gotAliases); diff != "" {
|
||||
t.Fatalf("final aliases differ from BoundCommand: %s", diff)
|
||||
}
|
||||
|
||||
boundAliases := make(map[string]cli.BoundAlias, len(command.AliasCommands))
|
||||
for _, alias := range command.AliasCommands {
|
||||
if _, duplicate := boundAliases[alias.Path]; duplicate {
|
||||
t.Fatalf("BoundCommand has duplicate alias %q", alias.Path)
|
||||
}
|
||||
boundAliases[alias.Path] = alias
|
||||
}
|
||||
for _, aliasPath := range wantAliases {
|
||||
alias, ok := boundAliases[aliasPath]
|
||||
if !ok {
|
||||
t.Fatalf("registry alias %q has no BoundAlias", aliasPath)
|
||||
}
|
||||
aliasMatch, err := resolveSchemaBaseCommandPath(root, aliasPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve alias %q exactly: %v", aliasPath, err)
|
||||
}
|
||||
if aliasMatch.command == nil {
|
||||
t.Fatalf("bound alias %q does not exist in live Cobra tree", aliasPath)
|
||||
}
|
||||
if aliasMatch.command != alias.Command {
|
||||
t.Fatalf("BoundAlias pointer differs from exact live Cobra path %q", aliasPath)
|
||||
}
|
||||
assertRunnableSchemaBaseCommand(t, alias.Command, aliasPath)
|
||||
switch alias.Kind {
|
||||
case cli.AliasKindCobraAlias:
|
||||
if !aliasMatch.usedAlias || alias.Command != command.PrimaryCommand {
|
||||
t.Fatalf("Cobra alias %q must resolve through Aliases to the primary command pointer", aliasPath)
|
||||
}
|
||||
case cli.AliasKindCompatibilityLeaf:
|
||||
if aliasMatch.usedAlias || alias.Command == command.PrimaryCommand {
|
||||
t.Fatalf("compatibility alias %q must be a separate exact-name Cobra leaf", aliasPath)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("alias %q has unknown binding kind %q", aliasPath, alias.Kind)
|
||||
}
|
||||
if indexed, ok := bound.ByCLIPath[aliasPath]; !ok || indexed.CanonicalPath != canonical {
|
||||
t.Fatalf("BoundCommandRegistry path index %q does not resolve to %q", aliasPath, canonical)
|
||||
}
|
||||
}
|
||||
if len(boundAliases) != len(wantAliases) {
|
||||
t.Fatalf("BoundCommand exposes %d alias bindings for %d reviewed aliases", len(boundAliases), len(wantAliases))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Logf("validated %d final Schema tools and their executable base commands", len(finalCanonicals))
|
||||
}
|
||||
|
||||
func assertRunnableSchemaBaseCommand(t *testing.T, command *cobra.Command, path string) {
|
||||
t.Helper()
|
||||
if command == nil || !command.Runnable() || command.HasSubCommands() {
|
||||
t.Fatalf("Schema path %q does not bind a runnable Cobra leaf", path)
|
||||
}
|
||||
}
|
||||
|
||||
type schemaBaseCommandPathMatch struct {
|
||||
command *cobra.Command
|
||||
usedAlias bool
|
||||
}
|
||||
|
||||
// resolveSchemaBaseCommandPath independently resolves exact Cobra names and
|
||||
// aliases for the delivery contract test. Like the production binder, it does
|
||||
// not accept Cobra prefix matching or suggestions.
|
||||
func resolveSchemaBaseCommandPath(root *cobra.Command, rawPath string) (schemaBaseCommandPathMatch, error) {
|
||||
parts := strings.Fields(strings.TrimSpace(rawPath))
|
||||
if len(parts) > 0 && root != nil && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if root == nil || len(parts) == 0 {
|
||||
return schemaBaseCommandPathMatch{}, nil
|
||||
}
|
||||
|
||||
current := root
|
||||
usedAlias := false
|
||||
for _, part := range parts {
|
||||
exact := schemaBaseCommandChildrenNamed(current, part, false)
|
||||
if len(exact) > 1 {
|
||||
return schemaBaseCommandPathMatch{}, fmt.Errorf("command segment %q is ambiguous", part)
|
||||
}
|
||||
if len(exact) == 1 {
|
||||
current = exact[0]
|
||||
continue
|
||||
}
|
||||
aliases := schemaBaseCommandChildrenNamed(current, part, true)
|
||||
if len(aliases) > 1 {
|
||||
return schemaBaseCommandPathMatch{}, fmt.Errorf("alias segment %q is ambiguous", part)
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return schemaBaseCommandPathMatch{}, nil
|
||||
}
|
||||
current = aliases[0]
|
||||
usedAlias = true
|
||||
}
|
||||
return schemaBaseCommandPathMatch{command: current, usedAlias: usedAlias}, nil
|
||||
}
|
||||
|
||||
func schemaBaseCommandChildrenNamed(parent *cobra.Command, name string, aliases bool) []*cobra.Command {
|
||||
var matches []*cobra.Command
|
||||
for _, child := range parent.Commands() {
|
||||
matched := child.Name() == name
|
||||
if aliases {
|
||||
matched = false
|
||||
for _, alias := range child.Aliases {
|
||||
if alias == name {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
seen := false
|
||||
for _, existing := range matches {
|
||||
if existing == child {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
matches = append(matches, child)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func schemaBaseCommandString(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func schemaBaseCommandStringSlice(value any) []string {
|
||||
var values []string
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range values {
|
||||
values[index] = strings.TrimSpace(values[index])
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func schemaBaseCommandSetDiff(want, got []string) string {
|
||||
wantSet := make(map[string]bool, len(want))
|
||||
gotSet := make(map[string]bool, len(got))
|
||||
for _, value := range want {
|
||||
wantSet[value] = true
|
||||
}
|
||||
for _, value := range got {
|
||||
gotSet[value] = true
|
||||
}
|
||||
var missing, extra []string
|
||||
for value := range wantSet {
|
||||
if !gotSet[value] {
|
||||
missing = append(missing, value)
|
||||
}
|
||||
}
|
||||
for value := range gotSet {
|
||||
if !wantSet[value] {
|
||||
extra = append(extra, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
if len(missing) == 0 && len(extra) == 0 && len(want) == len(got) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("missing=%v extra=%v want_count=%d got_count=%d", missing, extra, len(want), len(got))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := NewRootCommand()
|
||||
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := cli.RuntimeSchemaCompleteness(root, exclusions)
|
||||
if len(report.Missing) > 0 || len(report.InvalidExclusions) > 0 || len(report.StaleExclusions) > 0 {
|
||||
t.Fatalf("runtime schema completeness: missing=%v invalid=%v stale=%v", report.Missing, report.InvalidExclusions, report.StaleExclusions)
|
||||
}
|
||||
if !containsSchemaPath(report.Covered, "chat category create-smart") {
|
||||
t.Fatal("chat category create-smart is not covered by runtime Schema")
|
||||
}
|
||||
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
|
||||
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
|
||||
}
|
||||
}
|
||||
|
||||
func containsSchemaPath(paths []string, want string) bool {
|
||||
for _, path := range paths {
|
||||
if path == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var (
|
||||
fullSchemaSnapshotOnce sync.Once
|
||||
fullSchemaSnapshot cli.SchemaCatalogSnapshot
|
||||
fullSchemaSnapshotErr error
|
||||
)
|
||||
|
||||
// fullSchemaSnapshotForTest runs the complete source-to-delivery invariant
|
||||
// once per test binary. The returned snapshot is a shared read-only fixture;
|
||||
// callers still build their own Cobra root when they need executable command
|
||||
// pointers. This preserves every full-Catalog assertion without paying the
|
||||
// multi-gigabyte generation/validation cost three times under -race.
|
||||
func fullSchemaSnapshotForTest(t testing.TB) cli.SchemaCatalogSnapshot {
|
||||
t.Helper()
|
||||
fullSchemaSnapshotOnce.Do(func() {
|
||||
resolved, err := cli.ResolveSchemaBuild(NewRootCommand())
|
||||
if err != nil {
|
||||
fullSchemaSnapshotErr = err
|
||||
return
|
||||
}
|
||||
fullSchemaSnapshot, fullSchemaSnapshotErr = cli.BuildSchemaCatalogSnapshot(resolved, cli.SchemaCatalogBuildOptions{})
|
||||
})
|
||||
if fullSchemaSnapshotErr != nil {
|
||||
t.Fatalf("build shared final Schema snapshot: %v", fullSchemaSnapshotErr)
|
||||
}
|
||||
return fullSchemaSnapshot
|
||||
}
|
||||
|
||||
func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := make(map[string]bool)
|
||||
for _, command := range effective.Commands {
|
||||
if command.Visibility == cli.SchemaVisibilityPublic {
|
||||
expected[command.CanonicalPath] = true
|
||||
}
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
Tools []struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
} `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
}
|
||||
actual := make(map[string]bool)
|
||||
var duplicates []string
|
||||
for _, product := range payload.Products {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(tool.CanonicalPath)
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
}
|
||||
if actual[canonical] {
|
||||
duplicates = append(duplicates, canonical)
|
||||
}
|
||||
actual[canonical] = true
|
||||
}
|
||||
}
|
||||
if len(duplicates) > 0 {
|
||||
sort.Strings(duplicates)
|
||||
t.Fatalf("embedded schema --all contains duplicate canonicals: %v", duplicates)
|
||||
}
|
||||
|
||||
var missing, extra []string
|
||||
for canonical := range expected {
|
||||
if !actual[canonical] {
|
||||
missing = append(missing, canonical)
|
||||
}
|
||||
}
|
||||
for canonical := range actual {
|
||||
if !expected[canonical] {
|
||||
extra = append(extra, canonical)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
t.Fatalf("embedded Schema canonical set differs from EffectiveCommandRegistry: missing=%v extra=%v", missing, extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
bindings, err := cli.EmbeddedSchemaParameterBindings()
|
||||
if err != nil {
|
||||
t.Fatalf("EmbeddedSchemaParameterBindings() error = %v", err)
|
||||
}
|
||||
if len(snapshot.Tools) == 0 {
|
||||
t.Fatal("generated Schema Catalog contains no tools")
|
||||
}
|
||||
for canonicalPath := range bindings {
|
||||
if _, ok := snapshot.Tools[canonicalPath]; !ok {
|
||||
t.Errorf("parameter bindings reference canonical %q that is absent from the final generated Schema", canonicalPath)
|
||||
}
|
||||
}
|
||||
|
||||
for canonicalPath, definition := range snapshot.Tools {
|
||||
cliPath := schemaContractString(definition["primary_cli_path"])
|
||||
if cliPath == "" {
|
||||
cliPath = schemaContractString(definition["cli_path"])
|
||||
}
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil {
|
||||
for _, alias := range schemaContractStringSlice(definition["aliases"]) {
|
||||
if command = exactCommandForTest(root, alias); command != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if command == nil {
|
||||
t.Errorf("%s has no executable CLI path %q", canonicalPath, cliPath)
|
||||
continue
|
||||
}
|
||||
for parameterName, rawParameter := range schemaContractMap(definition["parameters"]) {
|
||||
flag := schemaContractCommandFlag(command, parameterName)
|
||||
if flag == nil {
|
||||
t.Errorf("%s maps parameter %q to missing flag on %q", canonicalPath, parameterName, command.CommandPath())
|
||||
continue
|
||||
}
|
||||
if got, want := schemaContractFlagDefault(flag), schemaContractString(rawParameter["default"]); want != got {
|
||||
t.Errorf("%s parameter %q default = %q, Cobra --help default = %q", canonicalPath, parameterName, want, got)
|
||||
}
|
||||
}
|
||||
for flagName, propertyName := range bindings[canonicalPath] {
|
||||
flag := schemaContractCommandFlag(command, flagName)
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Errorf("%s binding --%s references a missing or hidden public flag", canonicalPath, flagName)
|
||||
continue
|
||||
}
|
||||
parameter := schemaContractMap(definition["parameters"])[flagName]
|
||||
if parameter == nil || schemaContractString(parameter["property"]) != propertyName {
|
||||
t.Errorf("%s binding --%s -> %s is absent from generated Catalog", canonicalPath, flagName, propertyName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaParameterMetadataMapsToGeneratedCatalog(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
|
||||
for canonicalPath, metadata := range cli.RuntimeSchemaParameterMetadataDefinitions() {
|
||||
tool := snapshot.Tools[canonicalPath]
|
||||
if tool == nil {
|
||||
t.Errorf("parameter metadata references unknown tool %q", canonicalPath)
|
||||
continue
|
||||
}
|
||||
parameters, _ := tool["parameters"].(map[string]any)
|
||||
parameter := func(flagName string) map[string]any {
|
||||
value, _ := parameters[flagName].(map[string]any)
|
||||
if value == nil {
|
||||
t.Errorf("%s parameter metadata references unknown flag --%s", canonicalPath, flagName)
|
||||
}
|
||||
return value
|
||||
}
|
||||
for _, flagName := range metadata.Inherited {
|
||||
parameter(flagName)
|
||||
}
|
||||
for _, flagName := range metadata.Required {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "required", true)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.RequiredWhen {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "required_when", want)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.Formats {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "format", want)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.Examples {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "example", want)
|
||||
}
|
||||
}
|
||||
for flagName, want := range metadata.Enums {
|
||||
if value := parameter(flagName); value != nil {
|
||||
assertRuntimeSchemaMetadataCandidate(t, canonicalPath, flagName, value, "enum", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertRuntimeSchemaMetadataCandidate verifies the field-level resolver
|
||||
// contract without assuming which source wins. Typed runtime metadata must
|
||||
// remain visible as a candidate, resolution must select exactly one candidate,
|
||||
// and the final payload/envelope must agree with that selected candidate.
|
||||
func assertRuntimeSchemaMetadataCandidate(t testing.TB, canonicalPath, flagName string, parameter map[string]any, field string, typedValue any) {
|
||||
t.Helper()
|
||||
fieldProvenance, _ := parameter["field_provenance"].(map[string]any)
|
||||
provenance, _ := fieldProvenance[field].(map[string]any)
|
||||
if provenance == nil {
|
||||
t.Errorf("%s --%s %s has no final resolver provenance", canonicalPath, flagName, field)
|
||||
return
|
||||
}
|
||||
|
||||
foundTypedCandidate := false
|
||||
var selected map[string]any
|
||||
selectedCount := 0
|
||||
for _, candidate := range schemaContractObjectSlice(provenance["candidates"]) {
|
||||
if candidate["source"] == "typed_parameter_metadata" && schemaContractJSONEqual(candidate["value"], typedValue) {
|
||||
foundTypedCandidate = true
|
||||
}
|
||||
if isSelected, _ := candidate["selected"].(bool); isSelected {
|
||||
selected = candidate
|
||||
selectedCount++
|
||||
}
|
||||
}
|
||||
if !foundTypedCandidate {
|
||||
t.Errorf("%s --%s %s provenance has no typed_parameter_metadata candidate with value %#v", canonicalPath, flagName, field, typedValue)
|
||||
}
|
||||
if selectedCount != 1 {
|
||||
t.Errorf("%s --%s %s provenance selected candidates = %d, want 1", canonicalPath, flagName, field, selectedCount)
|
||||
return
|
||||
}
|
||||
if got, want := parameter[field], selected["value"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s --%s final %s = %#v, selected provenance value = %#v", canonicalPath, flagName, field, got, want)
|
||||
}
|
||||
if got, want := provenance["value"], selected["value"]; !schemaContractJSONEqual(got, want) {
|
||||
t.Errorf("%s --%s %s provenance value = %#v, selected candidate value = %#v", canonicalPath, flagName, field, got, want)
|
||||
}
|
||||
if got, want := provenance["precedence"], selected["precedence"]; got != want {
|
||||
t.Errorf("%s --%s %s provenance precedence = %#v, selected candidate precedence = %#v", canonicalPath, flagName, field, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractJSONEqual(left, right any) bool {
|
||||
leftJSON, leftErr := json.Marshal(left)
|
||||
rightJSON, rightErr := json.Marshal(right)
|
||||
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
|
||||
}
|
||||
|
||||
func schemaContractObjectSlice(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if object, ok := item.(map[string]any); ok {
|
||||
out = append(out, object)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractFlagDefault(flag *pflag.Flag) string {
|
||||
if flag == nil {
|
||||
return ""
|
||||
}
|
||||
value := strings.TrimSpace(flag.DefValue)
|
||||
if value == "" || value == "0s" || value == "[]" || value == "{}" {
|
||||
return ""
|
||||
}
|
||||
switch flag.Value.Type() {
|
||||
case "bool":
|
||||
if value == "false" {
|
||||
return ""
|
||||
}
|
||||
case "int", "int8", "int16", "int32", "int64", "float32", "float64":
|
||||
if value == "0" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func schemaContractCommandFlag(command *cobra.Command, name string) *pflag.Flag {
|
||||
if command == nil {
|
||||
return nil
|
||||
}
|
||||
if flag := command.Flags().Lookup(name); flag != nil {
|
||||
return flag
|
||||
}
|
||||
for current := command; current != nil; current = current.Parent() {
|
||||
if flag := current.PersistentFlags().Lookup(name); flag != nil {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaContractMap(value any) map[string]map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]map[string]any:
|
||||
return typed
|
||||
case map[string]any:
|
||||
out := make(map[string]map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
if object, ok := item.(map[string]any); ok {
|
||||
out[key] = object
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaContractStringSlice(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// schemaContractPayloadForBoundCanonicals builds a small test fixture by
|
||||
// selecting already validated BoundCommand entries before Schema assembly.
|
||||
// Production generation deliberately has no post-assembly subset option: its
|
||||
// delivered set must always equal the public EffectiveCommandRegistry set.
|
||||
func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command, canonicals ...string) cli.SchemaSnapshotPayload {
|
||||
t.Helper()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
|
||||
t.Fatalf("apply manual Schema hints: %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build effective CommandRegistry: %v", err)
|
||||
}
|
||||
fixtureRegistry := cli.EffectiveCommandRegistry{Commands: make([]cli.CommandSpec, 0, len(canonicals))}
|
||||
for _, canonical := range canonicals {
|
||||
command, ok := effective.ByCanonical[canonical]
|
||||
if !ok {
|
||||
t.Fatalf("effective fixture has no canonical %s", canonical)
|
||||
}
|
||||
fixtureRegistry.Commands = append(fixtureRegistry.Commands, command)
|
||||
}
|
||||
fixture, err := cli.BindEffectiveCommandRegistry(root, fixtureRegistry)
|
||||
if err != nil {
|
||||
t.Fatalf("bind synthetic effective CommandRegistry: %v", err)
|
||||
}
|
||||
registry, err := cli.AssembleSchemaRegistryFromBound(fixture)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble synthetic bound Schema registry: %v", err)
|
||||
}
|
||||
payload, err := registry.ToSnapshotPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("render synthetic bound Schema registry: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"chat.send_personal_message",
|
||||
"chat.reply_personal_message",
|
||||
)
|
||||
|
||||
send, ok := snapshot.Tools["chat.send_personal_message"]
|
||||
if !ok || schemaContractString(send["primary_cli_path"]) != "chat message send" {
|
||||
t.Fatalf("send definition = %#v", send)
|
||||
}
|
||||
reply, ok := snapshot.Tools["chat.reply_personal_message"]
|
||||
if !ok || schemaContractString(reply["primary_cli_path"]) != "chat message reply" {
|
||||
t.Fatalf("reply definition = %#v", reply)
|
||||
}
|
||||
interfaceRef, _ := reply["interface_ref"].(map[string]any)
|
||||
if schemaContractString(interfaceRef["product_id"]) != "chat" || schemaContractString(interfaceRef["rpc_name"]) != "send_personal_message" {
|
||||
t.Fatalf("reply interface = %#v", interfaceRef)
|
||||
}
|
||||
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
|
||||
t.Fatal("downlined chat file upload must not be advertised in Schema")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, root, "calendar.remove_calendar_participant")
|
||||
tool := snapshot.Tools["calendar.remove_calendar_participant"]
|
||||
// Runtime does not gate this path today; Schema confirmation must follow metadata.runtime_gate.
|
||||
if got := tool["confirmation"]; got != "not_required" {
|
||||
t.Fatalf("calendar attendee delete confirmation = %#v, want not_required", got)
|
||||
}
|
||||
if got := tool["risk"]; got != "medium" {
|
||||
t.Fatalf("calendar attendee delete risk = %#v, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptingWritesRequireUserConfirmation(t *testing.T) {
|
||||
wantEffects := map[string]string{
|
||||
"attendance.class_create": "write",
|
||||
"attendance.class_update": "write",
|
||||
"doc.delete_comment": "write",
|
||||
"doc.version_revert": "write",
|
||||
"drive.publish_set": "write",
|
||||
"drive.publish_unset": "write",
|
||||
"sheet.chart_delete": "write",
|
||||
"sheet.delete_pivot_table": "write",
|
||||
}
|
||||
wantRisks := map[string]string{
|
||||
"attendance.class_create": "medium",
|
||||
"attendance.class_update": "medium",
|
||||
"doc.delete_comment": "medium",
|
||||
"doc.version_revert": "medium",
|
||||
"drive.publish_set": "medium",
|
||||
"drive.publish_unset": "medium",
|
||||
"sheet.chart_delete": "medium",
|
||||
"sheet.delete_pivot_table": "medium",
|
||||
}
|
||||
wantSources := map[string]string{
|
||||
"attendance.class_create": "internal/cli/schema_hints/metadata/attendance.json",
|
||||
"attendance.class_update": "internal/cli/schema_hints/metadata/attendance.json",
|
||||
"doc.delete_comment": "internal/cli/schema_hints/metadata/doc.json",
|
||||
"doc.version_revert": "internal/cli/schema_hints/metadata/doc.json",
|
||||
"drive.publish_set": "internal/cli/schema_hints/metadata/drive.json",
|
||||
"drive.publish_unset": "internal/cli/schema_hints/metadata/drive.json",
|
||||
"sheet.chart_delete": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"sheet.delete_pivot_table": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
}
|
||||
canonicals := make([]string, 0, len(wantEffects))
|
||||
for canonical := range wantEffects {
|
||||
canonicals = append(canonicals, canonical)
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
tool := snapshot.Tools[canonical]
|
||||
if got := tool["effect"]; got != wantEffects[canonical] {
|
||||
t.Errorf("%s effect = %#v, want %s", canonical, got, wantEffects[canonical])
|
||||
}
|
||||
if got := tool["risk"]; got != wantRisks[canonical] {
|
||||
t.Errorf("%s risk = %#v, want %s", canonical, got, wantRisks[canonical])
|
||||
}
|
||||
if got := tool["confirmation"]; got != "user_required" {
|
||||
t.Errorf("%s confirmation = %#v, want user_required", canonical, got)
|
||||
}
|
||||
provenance, _ := tool["field_provenance"].(map[string]any)
|
||||
for _, field := range []string{"effect", "risk", "confirmation"} {
|
||||
selected, _ := provenance[field].(map[string]any)
|
||||
if got := selected["source"]; got != wantSources[canonical] {
|
||||
t.Errorf("%s %s provenance source = %#v, want %s", canonical, field, got, wantSources[canonical])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMainCommandInterfaceConversionsReachFinalSchema(t *testing.T) {
|
||||
type conversion struct {
|
||||
canonical string
|
||||
flag string
|
||||
cliType string
|
||||
interfaceType string
|
||||
}
|
||||
wants := []conversion{
|
||||
{canonical: "chat.list_message_favorites", flag: "size", cliType: "integer", interfaceType: "string"},
|
||||
{canonical: "doc.update_comment", flag: "mention", cliType: "string", interfaceType: "array"},
|
||||
{canonical: "sheet.create_pivot_table", flag: "properties", cliType: "string", interfaceType: "object"},
|
||||
{canonical: "sheet.table_put", flag: "sheets", cliType: "string", interfaceType: "array"},
|
||||
{canonical: "sheet.update_pivot_table", flag: "properties", cliType: "string", interfaceType: "object"},
|
||||
}
|
||||
canonicals := make([]string, 0, len(wants))
|
||||
for _, want := range wants {
|
||||
canonicals = append(canonicals, want.canonical)
|
||||
}
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, want := range wants {
|
||||
tool := snapshot.Tools[want.canonical]
|
||||
parameters, _ := tool["parameters"].(map[string]any)
|
||||
parameter, _ := parameters[want.flag].(map[string]any)
|
||||
if got := schemaContractString(parameter["type"]); got != want.cliType {
|
||||
t.Errorf("%s --%s CLI type = %q, want %q", want.canonical, want.flag, got, want.cliType)
|
||||
}
|
||||
if got := schemaContractString(parameter["interface_type"]); got != want.interfaceType {
|
||||
t.Errorf("%s --%s interface_type = %q, want %q", want.canonical, want.flag, got, want.interfaceType)
|
||||
}
|
||||
}
|
||||
if got := snapshot.Tools["sheet.create_pivot_table"]["idempotency"]; got != "non_idempotent" {
|
||||
t.Errorf("sheet.create_pivot_table idempotency = %#v, want non_idempotent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultedPaginationSchemaFlagsAreOptional(t *testing.T) {
|
||||
wants := map[string][]string{
|
||||
"chat.search_messages_by_time_range": {"limit"},
|
||||
"oa.list_user_visible_process": {"cursor", "limit"},
|
||||
"report.get_received_report_list": {"cursor", "size"},
|
||||
"todo.get_user_todos_in_current_org": {"page"},
|
||||
}
|
||||
canonicals := make([]string, 0, len(wants)+1)
|
||||
for canonicalPath := range wants {
|
||||
canonicals = append(canonicals, canonicalPath)
|
||||
}
|
||||
canonicals = append(canonicals, "aitable.section_reorder")
|
||||
sort.Strings(canonicals)
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for canonicalPath, flags := range wants {
|
||||
parameters := schemaContractMap(snapshot.Tools[canonicalPath]["parameters"])
|
||||
for _, flagName := range flags {
|
||||
if parameters[flagName]["required"] == true {
|
||||
t.Errorf("%s --%s has a CLI default and must remain optional", canonicalPath, flagName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetIndex := schemaContractMap(snapshot.Tools["aitable.section_reorder"]["parameters"])["target-index"]
|
||||
if targetIndex["required"] != true {
|
||||
t.Error("aitable.section_reorder --target-index uses -1 as a sentinel and must remain required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATSchemaKeepsCLIContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, root, "pat.batch_grant")
|
||||
tool := payload.Tools["pat.batch_grant"]
|
||||
parameters, _ := tool["parameters"].(map[string]any)
|
||||
grantType, _ := parameters["grant-type"].(map[string]any)
|
||||
if grantType["default"] != "permanent" {
|
||||
t.Fatalf("grant-type default = %#v", grantType["default"])
|
||||
}
|
||||
positionals, _ := tool["positionals"].([]any)
|
||||
if len(positionals) != 1 {
|
||||
t.Fatalf("PAT positionals = %#v", tool["positionals"])
|
||||
}
|
||||
positional, _ := positionals[0].(map[string]any)
|
||||
if positional["name"] != "scope" || positional["variadic"] != true {
|
||||
t.Fatalf("PAT positionals = %#v", tool["positionals"])
|
||||
}
|
||||
}
|
||||
|
||||
func exactCommandForTest(root *cobra.Command, path string) *cobra.Command {
|
||||
parts := strings.Fields(strings.TrimSpace(path))
|
||||
if len(parts) > 0 && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
current := root
|
||||
for _, name := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == name {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
if current == root {
|
||||
return nil
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestFinalSchemaParametersMatchExecutableHelpFlags is the fast, in-process
|
||||
// Help <-> Schema parameter completeness gate. It deliberately starts from the
|
||||
// reviewed registry and its exact Cobra bindings, then compares every public
|
||||
// primary leaf with the final delivered ToolSpec projection. The binder has
|
||||
// already proved that reviewed compatibility leaves have the same executable
|
||||
// contract as their primary, so aliases do not create a second parameter
|
||||
// source here.
|
||||
func TestFinalSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, snapshot.Tools, "source-built final Schema")
|
||||
}
|
||||
|
||||
// TestEmbeddedSchemaParametersMatchExecutableHelpFlags runs the same exact-set
|
||||
// gate against the artifact that ships in the binary. Going through the real
|
||||
// schema --all command is intentional: a stale generated Catalog must fail
|
||||
// even when a fresh source-built snapshot would agree with Cobra Help.
|
||||
func TestEmbeddedSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, tools, "embedded schema --all")
|
||||
}
|
||||
|
||||
func boundSchemaCommandsForHelpFlagTest(t testing.TB, root *cobra.Command) cli.BoundCommandRegistry {
|
||||
t.Helper()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("bind EffectiveCommandRegistry to live Cobra tree: %v", err)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
func embeddedSchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) map[string]map[string]any {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
Tools []map[string]any `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
}
|
||||
tools := make(map[string]map[string]any)
|
||||
for _, product := range payload.Products {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(schemaContractString(tool["canonical_path"]))
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
}
|
||||
if _, exists := tools[canonical]; exists {
|
||||
t.Fatalf("embedded schema --all contains duplicate canonical %q", canonical)
|
||||
}
|
||||
tools[canonical] = tool
|
||||
}
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("embedded schema --all contains no tools")
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func assertSchemaParametersMatchExecutableHelpFlags(
|
||||
t testing.TB,
|
||||
bound cli.BoundCommandRegistry,
|
||||
tools map[string]map[string]any,
|
||||
source string,
|
||||
) {
|
||||
t.Helper()
|
||||
problems := schemaHelpFlagCompletenessProblems(bound, tools)
|
||||
checked := 0
|
||||
for _, command := range bound.Commands {
|
||||
if command.Visibility == cli.SchemaVisibilityPublic {
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("%s parameter surface differs from executable Cobra Help:\n%s", source, strings.Join(problems, "\n"))
|
||||
}
|
||||
t.Logf("validated Help-visible flags against %s parameters for %d public tools", source, checked)
|
||||
}
|
||||
|
||||
func TestSchemaHelpFlagCompletenessRejectsStaleCatalogFlags(t *testing.T) {
|
||||
leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("fresh", "", "new executable input")
|
||||
bound := cli.BoundCommandRegistry{Commands: []cli.BoundCommandSpec{{
|
||||
CommandSpec: cli.CommandSpec{
|
||||
CanonicalPath: "sample.run",
|
||||
PrimaryCLIPath: "sample run",
|
||||
Visibility: cli.SchemaVisibilityPublic,
|
||||
},
|
||||
PrimaryCommand: leaf,
|
||||
}}}
|
||||
tools := map[string]map[string]any{
|
||||
"sample.run": {
|
||||
"parameters": map[string]any{
|
||||
"stale": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
problems := schemaHelpFlagCompletenessProblems(bound, tools)
|
||||
joined := strings.Join(problems, "\n")
|
||||
if !strings.Contains(joined, `missing_in_schema=["fresh"]`) ||
|
||||
!strings.Contains(joined, `extra_in_schema=["stale"]`) {
|
||||
t.Fatalf("stale Catalog flag drift was not reported: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaHelpFlagCompletenessProblems(bound cli.BoundCommandRegistry, tools map[string]map[string]any) []string {
|
||||
var problems []string
|
||||
public := make(map[string]bool)
|
||||
checked := 0
|
||||
for _, command := range bound.Commands {
|
||||
if command.Visibility != cli.SchemaVisibilityPublic {
|
||||
continue
|
||||
}
|
||||
public[command.CanonicalPath] = true
|
||||
checked++
|
||||
tool, ok := tools[command.CanonicalPath]
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"canonical=%q path=%q missing final Schema tool",
|
||||
command.CanonicalPath,
|
||||
command.PrimaryCLIPath,
|
||||
))
|
||||
continue
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(
|
||||
command.CanonicalPath,
|
||||
command.PrimaryCLIPath,
|
||||
command.PrimaryCommand,
|
||||
tool,
|
||||
); problem != "" {
|
||||
problems = append(problems, problem)
|
||||
}
|
||||
}
|
||||
for canonical := range tools {
|
||||
if !public[canonical] {
|
||||
problems = append(problems, fmt.Sprintf("canonical=%q is an unexpected final Schema tool", canonical))
|
||||
}
|
||||
}
|
||||
if checked != len(tools) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"public BoundCommand count=%d final Schema tool count=%d",
|
||||
checked,
|
||||
len(tools),
|
||||
))
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
func TestSchemaHelpFlagCompletenessRejectsAncestorPersistentLeak(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "output format")
|
||||
product := &cobra.Command{Use: "product"}
|
||||
product.PersistentFlags().String("leaked", "", "product-scoped option")
|
||||
leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("declared", "", "declared option")
|
||||
leaf.Flags().String("json", "", "Base JSON object payload for this tool invocation")
|
||||
leaf.Flags().String("hidden", "", "internal option")
|
||||
_ = leaf.Flags().MarkHidden("hidden")
|
||||
root.AddCommand(product)
|
||||
product.AddCommand(leaf)
|
||||
|
||||
tool := map[string]any{
|
||||
"parameters": map[string]any{
|
||||
"declared": map[string]any{"type": "string"},
|
||||
},
|
||||
}
|
||||
problem := schemaHelpFlagCompletenessProblem("product.run", "product run", leaf, tool)
|
||||
if !strings.Contains(problem, `missing_in_schema=["leaked"]`) {
|
||||
t.Fatalf("ancestor persistent leak was not reported: %s", problem)
|
||||
}
|
||||
if strings.Contains(problem, "format") || strings.Contains(problem, "json") || strings.Contains(problem, "hidden") {
|
||||
t.Fatalf("root controls and reviewed non-Schema flags must be excluded: %s", problem)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaHelpFlagCompletenessProblem(canonical, path string, command *cobra.Command, tool map[string]any) string {
|
||||
helpFlags := schemaHelpVisibleFlagNames(command)
|
||||
schemaFlags := make(map[string]bool)
|
||||
for name := range schemaContractMap(tool["parameters"]) {
|
||||
schemaFlags[name] = true
|
||||
}
|
||||
|
||||
missing := schemaFlagNameDifference(helpFlags, schemaFlags)
|
||||
extra := schemaFlagNameDifference(schemaFlags, helpFlags)
|
||||
if len(missing) == 0 && len(extra) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"canonical=%q path=%q missing_in_schema=%s extra_in_schema=%s",
|
||||
canonical,
|
||||
path,
|
||||
schemaQuotedFlagNames(missing),
|
||||
schemaQuotedFlagNames(extra),
|
||||
)
|
||||
}
|
||||
|
||||
// schemaHelpVisibleFlagNames models Cobra's leaf Help surface without rendering
|
||||
// text. Local flags and ancestor persistent flags are executable tool inputs.
|
||||
// Only the reviewed root execution controls are omitted; an unexpected new
|
||||
// root persistent flag must fail this gate instead of being silently treated as
|
||||
// process scaffolding.
|
||||
func schemaHelpVisibleFlagNames(command *cobra.Command) map[string]bool {
|
||||
visible := make(map[string]bool)
|
||||
if command == nil {
|
||||
return visible
|
||||
}
|
||||
|
||||
visit := func(flag *pflag.Flag, rootPersistent bool) {
|
||||
if flag == nil || flag.Hidden || flag.Name == "help" || schemaGenericPayloadEscapeHatch(flag) {
|
||||
return
|
||||
}
|
||||
if rootPersistent && schemaRootExecutionControl(flag.Name) {
|
||||
return
|
||||
}
|
||||
visible[flag.Name] = true
|
||||
}
|
||||
command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { visit(flag, false) })
|
||||
command.PersistentFlags().VisitAll(func(flag *pflag.Flag) { visit(flag, false) })
|
||||
root := command.Root()
|
||||
for parent := command.Parent(); parent != nil; parent = parent.Parent() {
|
||||
isRoot := parent == root
|
||||
parent.PersistentFlags().VisitAll(func(flag *pflag.Flag) { visit(flag, isRoot) })
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func schemaRootExecutionControl(name string) bool {
|
||||
switch name {
|
||||
case "client-id", "client-secret", "debug", "dry-run", "fields", "format", "jq", "mock",
|
||||
"output", "profile", "timeout", "token", "verbose", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func schemaGenericPayloadEscapeHatch(flag *pflag.Flag) bool {
|
||||
if flag == nil {
|
||||
return false
|
||||
}
|
||||
switch flag.Name {
|
||||
case "json":
|
||||
return strings.TrimSpace(flag.Usage) == "Base JSON object payload for this tool invocation"
|
||||
case "params":
|
||||
return strings.TrimSpace(flag.Usage) == "Additional JSON object payload merged after --json"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func schemaFlagNameDifference(left, right map[string]bool) []string {
|
||||
result := make([]string, 0)
|
||||
for name := range left {
|
||||
if !right[name] {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func schemaQuotedFlagNames(names []string) string {
|
||||
quoted := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", name))
|
||||
}
|
||||
return "[" + strings.Join(quoted, ",") + "]"
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
type interfaceCase struct {
|
||||
canonical string
|
||||
mode string
|
||||
reason string
|
||||
sourceSuffix string
|
||||
}
|
||||
tests := []interfaceCase{
|
||||
{
|
||||
canonical: "attendance.get_attendance_summary",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls attendance-wukong/get_user_attendance_summary, which is absent from the pinned MCP metadata snapshot; the incompatible attendance/get_attendance_summary contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/attendance.json",
|
||||
},
|
||||
{
|
||||
canonical: "drive.list_files",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --workspace between drive/list_files and doc/list_nodes, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/drive.json",
|
||||
},
|
||||
{
|
||||
canonical: "chat.search_groups",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls im/search_groups with a flat payload, while the pinned snapshot only contains the incompatible chat/search_groups_by_keyword contract.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/chat.json",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls sheet/get_cell_infos, which is absent from the pinned MCP metadata snapshot; the incompatible sheet/get_range contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
},
|
||||
{
|
||||
canonical: "wiki.list_wikiSpaces",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --type between wiki/list_wikiSpaces and drive/list_spaces, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/wiki.json",
|
||||
},
|
||||
{
|
||||
canonical: "event.consume",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
},
|
||||
{
|
||||
canonical: "event.status",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command reads the remote personal-event subscription control plane and combines it with local bus and consumer state; no single pinned RPC represents the result.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
},
|
||||
{
|
||||
canonical: "event.stop",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command deletes remote personal-event subscriptions, interrupts local consumers, updates local state, and may stop the local bus; no single pinned RPC represents the workflow.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
},
|
||||
}
|
||||
for _, canonical := range []string{
|
||||
"aitable.view_update_aggregate",
|
||||
"aitable.view_update_card",
|
||||
"aitable.view_update_field_widths",
|
||||
"aitable.view_update_timebar",
|
||||
} {
|
||||
tests = append(tests, interfaceCase{
|
||||
canonical: canonical,
|
||||
mode: "composite",
|
||||
reason: "The CLI performs an aitable/get_views preflight, locally transforms the requested configuration, and then calls aitable/update_view; the two-call workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/aitable.json",
|
||||
})
|
||||
}
|
||||
|
||||
canonicals := make([]string, 0, len(tests))
|
||||
for _, test := range tests {
|
||||
canonicals = append(canonicals, test.canonical)
|
||||
}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.canonical, func(t *testing.T) {
|
||||
tool := payload.Tools[test.canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != test.mode {
|
||||
t.Errorf("interface_mode = %q, want %q", got, test.mode)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("availability = %q, want available", got)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("interface_ref = %#v, want nil for %s wrapper", tool["interface_ref"], test.mode)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != test.reason {
|
||||
t.Errorf("interface_reason = %q, want %q", got, test.reason)
|
||||
}
|
||||
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
entry := provenance[field]
|
||||
if entry == nil {
|
||||
t.Errorf("missing %s provenance", field)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.HasSuffix(got, test.sourceSuffix) {
|
||||
t.Errorf("%s provenance source = %q, want suffix %q", field, got, test.sourceSuffix)
|
||||
}
|
||||
}
|
||||
if got := provenance["interface_ref"]["value"]; got != nil {
|
||||
t.Errorf("interface_ref provenance value = %#v, want explicit null", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewGetWrappersUsePinnedGetViewsInterface(t *testing.T) {
|
||||
canonicals := []string{
|
||||
"aitable.view_get_aggregate",
|
||||
"aitable.view_get_card",
|
||||
"aitable.view_get_field_widths",
|
||||
"aitable.view_get_fill_color_rule",
|
||||
"aitable.view_get_filter",
|
||||
"aitable.view_get_group",
|
||||
"aitable.view_get_sort",
|
||||
"aitable.view_get_timebar",
|
||||
"aitable.view_get_visible_fields",
|
||||
}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
tool := payload.Tools[canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "mcp" {
|
||||
t.Errorf("%s interface_mode = %q, want mcp", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability = %q, want available", canonical, got)
|
||||
}
|
||||
ref := schemaInterfaceObject(tool["interface_ref"])
|
||||
if product, rpc := schemaContractString(ref["product_id"]), schemaContractString(ref["rpc_name"]); product != "aitable" || rpc != "get_views" {
|
||||
t.Errorf("%s interface_ref = %q/%q, want aitable/get_views", canonical, product, rpc)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != "" {
|
||||
t.Errorf("%s interface_reason = %q, want empty for direct pinned interface", canonical, got)
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for flag, property := range map[string]string{
|
||||
"base-id": "baseId",
|
||||
"table-id": "tableId",
|
||||
"view-id": "viewIds",
|
||||
} {
|
||||
if got := schemaContractString(parameters[flag]["property"]); got != property {
|
||||
t.Errorf("%s --%s property = %q, want %q", canonical, flag, got, property)
|
||||
}
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref"} {
|
||||
entry := provenance[field]
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s %s precedence = %q, want reviewed_explicit", canonical, field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
|
||||
t.Errorf("%s %s source = %q, want reviewed interface disposition source", canonical, field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface(t *testing.T) {
|
||||
type hintFile struct {
|
||||
Source map[string]any `json:"source"`
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
load := func(path string) hintFile {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value hintFile
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
runtimeSurface := load("../cli/schema_hints/runtime-surface-completeness.json")
|
||||
legacyDispositionKeys := load("../cli/schema_hints/zz-interface-disposition-review.json").Tools
|
||||
dispositions := hintFile{Source: map[string]any{"reviewed": true}, Tools: map[string]map[string]any{}}
|
||||
for _, product := range []string{
|
||||
"attendance", "aitable", "chat", "drive", "event", "sheet", "wiki", "doc", "mail", "todo", "calendar", "conference", "contact", "dev", "devdoc", "ding", "live", "minutes", "oa", "pat", "report", "aisearch",
|
||||
} {
|
||||
path := "../cli/schema_hints/metadata/" + product + ".json"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
file := load(path)
|
||||
for canonical, hint := range file.Tools {
|
||||
if _, ok := legacyDispositionKeys[canonical]; !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := map[string]any{}
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
if value, exists := hint[field]; exists {
|
||||
trimmed[field] = value
|
||||
}
|
||||
}
|
||||
dispositions.Tools[canonical] = trimmed
|
||||
}
|
||||
}
|
||||
if dispositions.Source["reviewed"] != true {
|
||||
t.Fatalf("interface disposition source reviewed = %#v, want true", dispositions.Source["reviewed"])
|
||||
}
|
||||
for canonical, hint := range runtimeSurface.Tools {
|
||||
if hint["reviewed"] != false {
|
||||
t.Errorf("%s runtime surface reviewed = %#v, want false", canonical, hint["reviewed"])
|
||||
}
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
if _, exists := hint[field]; exists {
|
||||
t.Errorf("%s runtime surface still owns %s", canonical, field)
|
||||
}
|
||||
}
|
||||
if _, exists := dispositions.Tools[canonical]; !exists {
|
||||
t.Errorf("%s runtime surface has no reviewed interface disposition", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
allowedFields := map[string]bool{
|
||||
"interface_mode": true,
|
||||
"availability": true,
|
||||
"interface_ref": true,
|
||||
"interface_reason": true,
|
||||
}
|
||||
canonicals := make([]string, 0, len(dispositions.Tools))
|
||||
for canonical, hint := range dispositions.Tools {
|
||||
canonicals = append(canonicals, canonical)
|
||||
for field := range hint {
|
||||
if !allowedFields[field] {
|
||||
t.Errorf("%s interface-only source contains non-interface field %s", canonical, field)
|
||||
}
|
||||
}
|
||||
mode := schemaContractString(hint["interface_mode"])
|
||||
if mode == "local" {
|
||||
t.Errorf("%s remote interface review is incorrectly classified local", canonical)
|
||||
}
|
||||
if schemaContractString(hint["availability"]) != "available" {
|
||||
t.Errorf("%s reviewed disposition is not available", canonical)
|
||||
}
|
||||
switch mode {
|
||||
case "mcp":
|
||||
ref := schemaInterfaceObject(hint["interface_ref"])
|
||||
if schemaContractString(ref["product_id"]) == "" || schemaContractString(ref["rpc_name"]) == "" {
|
||||
t.Errorf("%s reviewed mcp disposition has no complete interface_ref", canonical)
|
||||
}
|
||||
case "composite":
|
||||
if hint["interface_ref"] != nil {
|
||||
t.Errorf("%s reviewed composite disposition advertises interface_ref %#v", canonical, hint["interface_ref"])
|
||||
}
|
||||
if schemaContractString(hint["interface_reason"]) == "" {
|
||||
t.Errorf("%s reviewed composite disposition has no reason", canonical)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s reviewed disposition mode = %q", canonical, mode)
|
||||
}
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
want := dispositions.Tools[canonical]
|
||||
tool := payload.Tools[canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != schemaContractString(want["interface_mode"]) {
|
||||
t.Errorf("%s final interface_mode = %q, want %q", canonical, got, want["interface_mode"])
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != schemaContractString(want["availability"]) {
|
||||
t.Errorf("%s final availability = %q, want %q", canonical, got, want["availability"])
|
||||
}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s final composite interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != schemaContractString(want["interface_reason"]) {
|
||||
t.Errorf("%s final interface_reason = %q, want %q", canonical, got, want["interface_reason"])
|
||||
}
|
||||
} else {
|
||||
gotRef := schemaInterfaceObject(tool["interface_ref"])
|
||||
wantRef := schemaInterfaceObject(want["interface_ref"])
|
||||
for _, field := range []string{"product_id", "rpc_name"} {
|
||||
if got := schemaContractString(gotRef[field]); got != schemaContractString(wantRef[field]) {
|
||||
t.Errorf("%s final interface_ref.%s = %q, want %q", canonical, field, got, wantRef[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
fields := []string{"interface_mode", "availability", "interface_ref"}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
fields = append(fields, "interface_reason")
|
||||
}
|
||||
for _, field := range fields {
|
||||
entry := provenance[field]
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s final %s precedence = %q, want reviewed_explicit", canonical, field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
|
||||
t.Errorf("%s final %s source = %q, want reviewed disposition source", canonical, field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func schemaInterfaceObject(value any) map[string]any {
|
||||
object, _ := value.(map[string]any)
|
||||
return object
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const schemaLazyStartupChildEnv = "DWS_SCHEMA_LAZY_STARTUP_CHILD"
|
||||
|
||||
// TestOrdinaryRootCommandsDoNotLoadSchemaMetadata uses a fresh process so its
|
||||
// counters describe package init, root construction, help, and version only;
|
||||
// unrelated Schema tests cannot have initialized the snapshots first.
|
||||
func TestOrdinaryRootCommandsDoNotLoadSchemaMetadata(t *testing.T) {
|
||||
if os.Getenv(schemaLazyStartupChildEnv) == "1" {
|
||||
assertSchemaMetadataNotLoaded(t, "package init")
|
||||
|
||||
root := NewRootCommand()
|
||||
assertSchemaMetadataNotLoaded(t, "NewRootCommand")
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("root --help: %v", err)
|
||||
}
|
||||
assertSchemaMetadataNotLoaded(t, "root --help")
|
||||
|
||||
root = NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("dws version: %v", err)
|
||||
}
|
||||
assertSchemaMetadataNotLoaded(t, "dws version")
|
||||
return
|
||||
}
|
||||
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestOrdinaryRootCommandsDoNotLoadSchemaMetadata$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), schemaLazyStartupChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("lazy startup child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaMetadataNotLoaded(t *testing.T, stage string) {
|
||||
t.Helper()
|
||||
if counts := cli.RuntimeSchemaMetadataLoadCounts(); counts != (cli.SchemaMetadataLoadCounts{}) {
|
||||
t.Fatalf("%s loaded Schema metadata: %#v", stage, counts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEventRegistryDeliversOneTypedSchemaPath(t *testing.T) {
|
||||
paths := map[string]string{
|
||||
"event.consume": "event consume",
|
||||
"event.list": "event list",
|
||||
"event.schema": "event schema",
|
||||
"event.status": "event status",
|
||||
"event.stop": "event stop",
|
||||
}
|
||||
wantModes := map[string]string{
|
||||
"event.consume": "composite",
|
||||
"event.list": "local",
|
||||
"event.schema": "local",
|
||||
"event.status": "composite",
|
||||
"event.stop": "composite",
|
||||
}
|
||||
canonicals := make([]string, 0, len(paths))
|
||||
for canonical := range paths {
|
||||
canonicals = append(canonicals, canonical)
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
if len(payload.Tools) != len(paths) {
|
||||
t.Fatalf("event fixture tools = %d, want %d", len(payload.Tools), len(paths))
|
||||
}
|
||||
if _, exists := payload.Tools["event._bus"]; exists {
|
||||
t.Fatal("hidden event _bus leaked into final Schema")
|
||||
}
|
||||
for canonical, primary := range paths {
|
||||
tool := payload.Tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("missing event tool %s", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != primary {
|
||||
t.Errorf("%s primary path = %q, want %q", canonical, got, primary)
|
||||
}
|
||||
if tool["interface_mode"] != wantModes[canonical] || tool["availability"] != "available" {
|
||||
t.Errorf("%s interface disposition = %v/%v, want %s/available", canonical, tool["interface_mode"], tool["availability"], wantModes[canonical])
|
||||
}
|
||||
if schemaContractString(tool["interface_reason"]) == "" {
|
||||
t.Errorf("%s has no reviewed interface reason", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
consume := payload.Tools["event.consume"]
|
||||
consumeParams := schemaContractMap(consume["parameters"])
|
||||
for _, hiddenOrGlobal := range []string{"as", "debug", "help", "profile", "timeout", "yes"} {
|
||||
if _, exists := consumeParams[hiddenOrGlobal]; exists {
|
||||
t.Errorf("event.consume exposes hidden/global flag --%s as a tool parameter", hiddenOrGlobal)
|
||||
}
|
||||
}
|
||||
for flag, wantType := range map[string]string{
|
||||
"dry-run": "boolean",
|
||||
"duration": "string",
|
||||
"event-types": "array",
|
||||
"max-events": "integer",
|
||||
} {
|
||||
if got := schemaContractString(consumeParams[flag]["type"]); got != wantType {
|
||||
t.Errorf("event.consume --%s type = %q, want %q", flag, got, wantType)
|
||||
}
|
||||
}
|
||||
if _, exists := consumeParams["duration"]["default"]; exists {
|
||||
t.Error("event.consume --duration leaked zero default 0s")
|
||||
}
|
||||
if consume["dry_run"] != nil {
|
||||
t.Errorf("event.consume declares an audited dry_run capability without a deterministic clean-environment preview: %#v", consume["dry_run"])
|
||||
}
|
||||
assertSchemaContractPositional(t, consume, "event_key", false)
|
||||
assertSchemaContractConstraintGroup(t, consume, "require_one_of", []string{"event_key", "subscribe-id"})
|
||||
|
||||
eventSchema := payload.Tools["event.schema"]
|
||||
assertSchemaContractPositional(t, eventSchema, "event_key", true)
|
||||
|
||||
stop := payload.Tools["event.stop"]
|
||||
if stop["effect"] != "destructive" || stop["risk"] != "high" || stop["confirmation"] != "user_required" {
|
||||
t.Errorf("event.stop safety = effect:%v risk:%v confirmation:%v", stop["effect"], stop["risk"], stop["confirmation"])
|
||||
}
|
||||
dryRun, _ := stop["dry_run"].(map[string]any)
|
||||
if dryRun["preview_kind"] != "request" {
|
||||
t.Errorf("event.stop dry_run = %#v, want request preview", stop["dry_run"])
|
||||
}
|
||||
assertSchemaContractPositional(t, stop, "subscribe_id", false)
|
||||
assertSchemaContractConstraintGroup(t, stop, "require_one_of", []string{"all", "subscribe_id"})
|
||||
assertSchemaContractConstraintGroup(t, stop, "mutually_exclusive", []string{"all", "subscribe_id"})
|
||||
}
|
||||
|
||||
func TestDevDocSearchBindingAndRequiredAlternativesReachFinalSchema(t *testing.T) {
|
||||
canonicals := []string{"dev.search_open_platform_docs_rag", "devdoc.search_open_platform_docs_rag"}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
for _, canonical := range canonicals {
|
||||
tool := payload.Tools[canonical]
|
||||
query := schemaContractMap(tool["parameters"])["query"]
|
||||
if got := schemaContractString(query["property"]); got != "keyword" {
|
||||
t.Errorf("%s --query property = %q, want keyword", canonical, got)
|
||||
}
|
||||
if query["required"] != false {
|
||||
t.Errorf("%s --query required = %#v, want false because positional keyword is an alternative", canonical, query["required"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"query", "keyword"})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaContractPositional(t *testing.T, tool map[string]any, name string, required bool) {
|
||||
t.Helper()
|
||||
positionals, _ := tool["positionals"].([]any)
|
||||
for _, raw := range positionals {
|
||||
positional, _ := raw.(map[string]any)
|
||||
if positional["name"] == name {
|
||||
if positional["required"] != required {
|
||||
t.Errorf("positional %s required = %#v, want %v", name, positional["required"], required)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("missing positional %s in %#v", name, tool["positionals"])
|
||||
}
|
||||
|
||||
func assertSchemaContractConstraintGroup(t *testing.T, tool map[string]any, kind string, want []string) {
|
||||
t.Helper()
|
||||
constraints, _ := tool["constraints"].(map[string]any)
|
||||
groups, _ := constraints[kind].([]any)
|
||||
for _, rawGroup := range groups {
|
||||
rawValues, _ := rawGroup.([]any)
|
||||
values := make([]string, 0, len(rawValues))
|
||||
for _, value := range rawValues {
|
||||
if text, ok := value.(string); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
if reflect.DeepEqual(values, want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("constraint %s lacks group %v: %#v", kind, want, constraints[kind])
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
// Keep the folder-list wrapper and the free-form KQL search command as two
|
||||
// independent Agent contracts. The list command translates --folder-id into a
|
||||
// query before calling the same RPC, so treating it as a search alias loses a
|
||||
// real executable parameter surface.
|
||||
func TestMailListAndSearchRemainDistinctRegistryCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
list, listOK := effective.ByCanonical["mail.list_emails"]
|
||||
search, searchOK := effective.ByCanonical["mail.search_emails"]
|
||||
if !listOK || !searchOK {
|
||||
t.Fatalf("mail registry split missing: list=%t search=%t", listOK, searchOK)
|
||||
}
|
||||
if list.PrimaryCLIPath != "mail message list" || search.PrimaryCLIPath != "mail message search" {
|
||||
t.Fatalf("mail registry paths: list=%q search=%q", list.PrimaryCLIPath, search.PrimaryCLIPath)
|
||||
}
|
||||
if len(list.Aliases) != 0 || len(search.Aliases) != 0 {
|
||||
t.Fatalf("mail list/search must not alias each other: list=%v search=%v", list.Aliases, search.Aliases)
|
||||
}
|
||||
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
listCommand := bound.ByCanonical[list.CanonicalPath].PrimaryCommand
|
||||
searchCommand := bound.ByCanonical[search.CanonicalPath].PrimaryCommand
|
||||
if listCommand == nil || searchCommand == nil || listCommand == searchCommand {
|
||||
t.Fatalf("mail list/search Cobra bindings are not distinct: list=%p search=%p", listCommand, searchCommand)
|
||||
}
|
||||
if listCommand.Flags().Lookup("folder-id") == nil || listCommand.Flags().Lookup("query") != nil {
|
||||
t.Fatal("mail list Cobra surface must expose --folder-id and not --query")
|
||||
}
|
||||
if searchCommand.Flags().Lookup("query") == nil || searchCommand.Flags().Lookup("folder-id") != nil {
|
||||
t.Fatal("mail search Cobra surface must expose --query and not --folder-id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
func TestMinutesFixedScopeLeavesBindToDistinctRegistryTools(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("bind EffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"minutes list all": "minutes.list_accessible_minutes",
|
||||
"minutes list mine": "minutes.list_by_keyword_and_time_range",
|
||||
"minutes list shared": "minutes.list_shared_minutes",
|
||||
}
|
||||
commands := map[any]bool{}
|
||||
canonicals := map[string]bool{}
|
||||
for path, canonical := range want {
|
||||
command, ok := bound.ByCLIPath[path]
|
||||
if !ok {
|
||||
t.Errorf("bound registry path %q is missing", path)
|
||||
continue
|
||||
}
|
||||
if command.CanonicalPath != canonical {
|
||||
t.Errorf("bound registry path %q canonical = %q, want %q", path, command.CanonicalPath, canonical)
|
||||
}
|
||||
if command.PrimaryCLIPath != path || len(command.Aliases) != 0 || len(command.AliasCommands) != 0 {
|
||||
t.Errorf("bound registry path %q retained alias navigation: primary=%q aliases=%v bound_aliases=%v", path, command.PrimaryCLIPath, command.Aliases, command.AliasCommands)
|
||||
}
|
||||
commands[command.PrimaryCommand] = true
|
||||
canonicals[command.CanonicalPath] = true
|
||||
}
|
||||
if len(commands) != len(want) || len(canonicals) != len(want) {
|
||||
t.Fatalf("minutes fixed-scope leaves collapsed: command pointers=%d canonicals=%d, want %d each", len(commands), len(canonicals), len(want))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSchemaReviewedInputMetadataUsesExecutableShapes(t *testing.T) {
|
||||
canonicals := []string{
|
||||
"aitable.field_update",
|
||||
"attendance.adjustment_search",
|
||||
"attendance.approve_list",
|
||||
"attendance.group_search",
|
||||
"attendance.overtime_search",
|
||||
"attendance.selfsetting_get",
|
||||
"attendance.vacation_update_type",
|
||||
"chat.list_owned_or_admin_groups",
|
||||
"sheet.batch_update",
|
||||
"sheet.chart_create",
|
||||
"sheet.chart_update",
|
||||
"sheet.create_pivot_table",
|
||||
"sheet.update_pivot_table",
|
||||
"sheet.range_batch_clear",
|
||||
"todo.add_todo_reminder",
|
||||
"todo.get_user_todos_in_current_org",
|
||||
}
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
|
||||
optional := false
|
||||
parameterCases := []struct {
|
||||
canonical string
|
||||
flag string
|
||||
format string
|
||||
enum []string
|
||||
noEnum bool
|
||||
example string
|
||||
required *bool
|
||||
requiredWhen string
|
||||
jsonKind byte
|
||||
}{
|
||||
{canonical: "attendance.approve_list", flag: "start", format: "date"},
|
||||
{canonical: "attendance.approve_list", flag: "end", format: "date"},
|
||||
{canonical: "attendance.approve_list", flag: "types", noEnum: true, example: "overtime,leave"},
|
||||
{canonical: "attendance.adjustment_search", flag: "page", required: &optional},
|
||||
{canonical: "attendance.adjustment_search", flag: "limit", required: &optional},
|
||||
{canonical: "attendance.group_search", flag: "page", required: &optional},
|
||||
{canonical: "attendance.group_search", flag: "limit", required: &optional},
|
||||
{canonical: "attendance.overtime_search", flag: "page", required: &optional},
|
||||
{canonical: "attendance.overtime_search", flag: "limit", required: &optional},
|
||||
{canonical: "attendance.selfsetting_get", flag: "setting-scene", enum: []string{
|
||||
"checkRemind", "fastCheck", "checkResultNotify", "lackRemind",
|
||||
"personalAttendStatNotify", "bossAttendStatNotify",
|
||||
}},
|
||||
{canonical: "chat.list_owned_or_admin_groups", flag: "role", enum: []string{"OWNER", "ADMIN"}, required: &optional},
|
||||
{canonical: "chat.list_owned_or_admin_groups", flag: "limit", required: &optional},
|
||||
{canonical: "sheet.batch_update", flag: "operations", format: "json", jsonKind: '['},
|
||||
{canonical: "sheet.chart_create", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.chart_update", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.create_pivot_table", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.update_pivot_table", flag: "properties", format: "json", jsonKind: '{'},
|
||||
{canonical: "sheet.range_batch_clear", flag: "ranges", format: "json", jsonKind: '['},
|
||||
{canonical: "todo.add_todo_reminder", flag: "base-time", enum: []string{"dueTime", "customTime"}},
|
||||
{canonical: "todo.add_todo_reminder", flag: "due-date-offset", example: "-30", requiredWhen: "base-time is dueTime"},
|
||||
{canonical: "todo.add_todo_reminder", flag: "reminder-time-stamp", example: "2026-03-10T18:00:00+08:00", requiredWhen: "base-time is customTime"},
|
||||
{canonical: "todo.get_user_todos_in_current_org", flag: "role-types", noEnum: true, example: "creator,executor"},
|
||||
}
|
||||
for _, test := range parameterCases {
|
||||
t.Run(test.canonical+"/"+test.flag, func(t *testing.T) {
|
||||
tool := payload.Tools[test.canonical]
|
||||
parameter := schemaContractMap(tool["parameters"])[test.flag]
|
||||
if test.format != "" && parameter["format"] != test.format {
|
||||
t.Fatalf("format = %#v, want %q", parameter["format"], test.format)
|
||||
}
|
||||
if test.enum != nil {
|
||||
if got := schemaContractStringSlice(parameter["enum"]); !reflect.DeepEqual(got, test.enum) {
|
||||
t.Fatalf("enum = %#v, want %#v", got, test.enum)
|
||||
}
|
||||
}
|
||||
if test.noEnum {
|
||||
if got := schemaContractStringSlice(parameter["enum"]); len(got) != 0 {
|
||||
t.Fatalf("enum = %#v, want no scalar enum for a CSV parameter", got)
|
||||
}
|
||||
}
|
||||
if test.example != "" && parameter["example"] != test.example {
|
||||
t.Fatalf("example = %#v, want %q", parameter["example"], test.example)
|
||||
}
|
||||
if test.required != nil && parameter["required"] != *test.required {
|
||||
t.Fatalf("required = %#v, want %v", parameter["required"], *test.required)
|
||||
}
|
||||
if test.requiredWhen != "" && parameter["required_when"] != test.requiredWhen {
|
||||
t.Fatalf("required_when = %#v, want %q", parameter["required_when"], test.requiredWhen)
|
||||
}
|
||||
if test.jsonKind != 0 {
|
||||
example, ok := parameter["example"].(string)
|
||||
if !ok || example == "" {
|
||||
t.Fatalf("example = %#v, want non-empty JSON", parameter["example"])
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(example), &decoded); err != nil {
|
||||
t.Fatalf("example is invalid JSON: %v", err)
|
||||
}
|
||||
if example[0] != test.jsonKind {
|
||||
t.Fatalf("example = %s, want JSON kind %q", example, test.jsonKind)
|
||||
}
|
||||
switch value := decoded.(type) {
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
t.Fatal("example must not be an empty array")
|
||||
}
|
||||
case map[string]any:
|
||||
if len(value) == 0 {
|
||||
t.Fatal("example must not be an empty object")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wantUpdateFields := []string{"name", "unit", "paid", "per-hours", "when-can-leave", "visibility-rules"}
|
||||
assertSchemaContractConstraintGroup(t, payload.Tools["attendance.vacation_update_type"], "require_one_of", wantUpdateFields)
|
||||
assertSchemaContractConstraintGroup(t, payload.Tools["aitable.field_update"], "require_one_of", []string{"name", "config", "ai-config"})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type finalSchemaSafetyWant struct {
|
||||
canonical string
|
||||
effect string
|
||||
risk string
|
||||
confirmation string
|
||||
idempotency string
|
||||
}
|
||||
|
||||
func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent"},
|
||||
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.batch_update", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.range_batch_clear", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.group_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.sort_filter", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.ungroup_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
}
|
||||
assertFinalSchemaSafety(t, wants)
|
||||
}
|
||||
|
||||
func TestDevAppWriteGuardRequiresFinalSchemaConfirmation(t *testing.T) {
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "dev.add_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.apply_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.create_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.create_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.delete_dev_app", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.disable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.disable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.enable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.enable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.publish_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.remove_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.remove_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.set_extension_robot_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.set_extension_webapp_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.submit_robot_create_task", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.subscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.unsubscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.update_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.update_dev_app_security_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
}
|
||||
assertFinalSchemaSafety(t, wants)
|
||||
}
|
||||
|
||||
func assertFinalSchemaSafety(t *testing.T, wants []finalSchemaSafetyWant) {
|
||||
t.Helper()
|
||||
canonicals := make([]string, 0, len(wants))
|
||||
for _, want := range wants {
|
||||
canonicals = append(canonicals, want.canonical)
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
|
||||
for _, want := range wants {
|
||||
want := want
|
||||
t.Run(want.canonical, func(t *testing.T) {
|
||||
tool := payload.Tools[want.canonical]
|
||||
values := map[string]string{
|
||||
"effect": want.effect,
|
||||
"risk": want.risk,
|
||||
"confirmation": want.confirmation,
|
||||
"idempotency": want.idempotency,
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for field, expected := range values {
|
||||
if got := schemaContractString(tool[field]); got != expected {
|
||||
t.Errorf("%s = %q, want %q", field, got, expected)
|
||||
}
|
||||
if got := schemaContractString(provenance[field]["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestSheetFinalSchemaConfirmationMatchesRuntimeGuards closes the delivery
|
||||
// invariant from the final typed Schema to the executable Cobra leaf. The
|
||||
// runtime marker can only be installed by the command-local wrapper that also
|
||||
// executes the typed confirmation guard.
|
||||
func TestSheetFinalSchemaConfirmationMatchesRuntimeGuards(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
root := NewRootCommand()
|
||||
|
||||
schemaPaths := make(map[string]string)
|
||||
for canonical, tool := range snapshot.Tools {
|
||||
primaryPath := schemaContractString(tool["primary_cli_path"])
|
||||
if primaryPath == "" {
|
||||
primaryPath = schemaContractString(tool["cli_path"])
|
||||
}
|
||||
if !strings.HasPrefix(primaryPath, "sheet ") || schemaContractString(tool["confirmation"]) != "user_required" {
|
||||
continue
|
||||
}
|
||||
if previous := schemaPaths[primaryPath]; previous != "" {
|
||||
t.Fatalf("final Schema maps both %q and %q to Sheet path %q", previous, canonical, primaryPath)
|
||||
}
|
||||
schemaPaths[primaryPath] = canonical
|
||||
|
||||
command := exactCommandForTest(root, primaryPath)
|
||||
if command == nil {
|
||||
t.Errorf("%s final Schema path %q has no executable Cobra leaf", canonical, primaryPath)
|
||||
continue
|
||||
}
|
||||
if !helpers.HasSheetMutationConfirmationGuard(command) {
|
||||
t.Errorf("%s (%s) declares confirmation=user_required but has no command-local runtime guard", canonical, primaryPath)
|
||||
}
|
||||
}
|
||||
if len(schemaPaths) == 0 {
|
||||
t.Fatal("final Schema contains no Sheet confirmation=user_required leaves")
|
||||
}
|
||||
|
||||
guardedPaths := make(map[string]bool)
|
||||
rootPrefix := root.CommandPath() + " "
|
||||
var visit func(*cobra.Command)
|
||||
visit = func(command *cobra.Command) {
|
||||
if helpers.HasSheetMutationConfirmationGuard(command) {
|
||||
path := strings.TrimPrefix(command.CommandPath(), rootPrefix)
|
||||
guardedPaths[path] = true
|
||||
}
|
||||
for _, child := range command.Commands() {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
|
||||
var missingGuards, undeclaredGuards []string
|
||||
for path, canonical := range schemaPaths {
|
||||
if !guardedPaths[path] {
|
||||
missingGuards = append(missingGuards, canonical+" ("+path+")")
|
||||
}
|
||||
}
|
||||
for path := range guardedPaths {
|
||||
if schemaPaths[path] == "" {
|
||||
undeclaredGuards = append(undeclaredGuards, path)
|
||||
}
|
||||
}
|
||||
if len(missingGuards) != 0 || len(undeclaredGuards) != 0 {
|
||||
sort.Strings(missingGuards)
|
||||
sort.Strings(undeclaredGuards)
|
||||
t.Fatalf("final Sheet Schema confirmation set differs from command-local runtime guards: missing=%v undeclared=%v", missingGuards, undeclaredGuards)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTodoListAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
const (
|
||||
canonicalPath = "todo.list_todo_attachment"
|
||||
cliPath = "todo task list-attachment"
|
||||
)
|
||||
|
||||
root := NewRootCommand()
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil {
|
||||
t.Fatalf("executable command %q is missing", cliPath)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode embedded schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != canonicalPath {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, canonicalPath)
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != cliPath {
|
||||
t.Fatalf("primary_cli_path = %q, want %q", got, cliPath)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(canonicalPath, cliPath, command, tool); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
|
||||
taskID := schemaContractMap(tool["parameters"])["task-id"]
|
||||
if taskID == nil {
|
||||
t.Fatal("delivered Schema is missing --task-id")
|
||||
}
|
||||
if required, ok := taskID["required"].(bool); !ok || !required {
|
||||
t.Fatalf("task-id required = %#v, want true", taskID["required"])
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill)
|
||||
dws skill setup --mode mono --yes # 非交互装 mono
|
||||
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
|
||||
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
|
||||
dws skill setup --mode multi -x live -x devdoc # 装其余 20 个,剔除 2 个
|
||||
dws skill setup --mode multi -x live -x devdoc # 安装除 live、devdoc 外的其余 skill
|
||||
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillSetup,
|
||||
@@ -216,8 +216,7 @@ func normalizeMultiSkillName(name string) string {
|
||||
return multiSkillPrefix + n
|
||||
}
|
||||
|
||||
// filterMultiSkillNames narrows `all` by include / exclude lists.
|
||||
// Semantics mirror lark-cli's `npx skills add -s lark-calendar`:
|
||||
// filterMultiSkillNames narrows `all` by include / exclude lists:
|
||||
//
|
||||
// - include + exclude are mutually exclusive (both → error)
|
||||
// - names accept short or full form; normalized before matching
|
||||
@@ -506,7 +505,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintln(out, " · 22 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
|
||||
@@ -44,6 +44,11 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
t.Errorf("expected embedded skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
|
||||
t.Fatal("embedded mono skill must not contain build-only schema-hints")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat embedded mono schema-hints: %v", err)
|
||||
}
|
||||
|
||||
// cleanup must actually remove the temp dir.
|
||||
cleanup()
|
||||
@@ -52,6 +57,35 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and the PAT product skill. Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeEmbeddedSkillSource: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if !isSkillSourceRoot(dir, skillSetupModeMulti) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dws-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "references", "pat.md"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
|
||||
t.Fatal("embedded multi skill must not contain build-only schema-hints")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat embedded multi schema-hints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
|
||||
// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
|
||||
// rather than probing the current working directory (the stale-skill footgun).
|
||||
|
||||
@@ -445,8 +445,7 @@ func TestFilterMultiSkillNames(t *testing.T) {
|
||||
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
|
||||
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
|
||||
// touch already-installed dingtalk-* siblings (additive semantics, matches
|
||||
// lark-cli `npx skills add -s lark-calendar`).
|
||||
// touch already-installed dingtalk-* siblings (additive semantics).
|
||||
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
|
||||
|
||||
@@ -15,6 +15,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
@@ -36,6 +37,21 @@ func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.To
|
||||
|
||||
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
|
||||
// Defense in depth for direct helper callers: global dry-run must never
|
||||
// reach an injected/real Runner, even if a command bypasses the normal
|
||||
// Schema leaf wrapper. EchoRunner produces the same stable dry_run envelope
|
||||
// without catalog, auth, Keychain, endpoint or transport access.
|
||||
if a != nil && a.DryRun() {
|
||||
inv.DryRun = true
|
||||
result, err := (executor.EchoRunner{}).Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertResult(result), nil
|
||||
}
|
||||
if a == nil || a.runner == nil {
|
||||
return nil, fmt.Errorf("ToolCaller runner is not configured")
|
||||
}
|
||||
result, err := a.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -44,25 +60,25 @@ func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName st
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Format() string {
|
||||
if a.flags != nil {
|
||||
if a != nil && a.flags != nil {
|
||||
return a.flags.Format
|
||||
}
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) DryRun() bool {
|
||||
return a.flags != nil && a.flags.DryRun
|
||||
return a != nil && a.flags != nil && a.flags.DryRun
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Fields() string {
|
||||
if a.flags != nil {
|
||||
if a != nil && a.flags != nil {
|
||||
return a.flags.Fields
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) JQ() string {
|
||||
if a.flags != nil {
|
||||
if a != nil && a.flags != nil {
|
||||
return a.flags.JQ
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// recordingToolCaller decorates a ToolCaller to record the SHAPE of every MCP
|
||||
// tool call into the local usage log (see internal/shortcut/usage). It is the
|
||||
// single chokepoint through which helper and shortcut commands dispatch, so one
|
||||
// wrapper captures all real usage. Recording never affects the call result and
|
||||
// is skipped for dry-run.
|
||||
type recordingToolCaller struct{ inner edition.ToolCaller }
|
||||
|
||||
func newRecordingToolCaller(inner edition.ToolCaller) edition.ToolCaller {
|
||||
return recordingToolCaller{inner: inner}
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
recordedArgs := cloneToolArgs(args)
|
||||
res, err := r.inner.CallTool(ctx, product, tool, args)
|
||||
usage.Append(product, tool, recordedArgs, err == nil, r.inner.DryRun())
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) Format() string { return r.inner.Format() }
|
||||
func (r recordingToolCaller) DryRun() bool { return r.inner.DryRun() }
|
||||
func (r recordingToolCaller) Fields() string { return r.inner.Fields() }
|
||||
func (r recordingToolCaller) JQ() string { return r.inner.JQ() }
|
||||
|
||||
func cloneToolArgs(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type crossPlatformCoverageCaller struct {
|
||||
args map[string]any
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.args = args
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (*crossPlatformCoverageCaller) Format() string { return "json" }
|
||||
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
|
||||
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
|
||||
func (*crossPlatformCoverageCaller) JQ() string { return ".result" }
|
||||
|
||||
func TestCrossPlatformCoverageCloneToolArgsDefensiveCopy(t *testing.T) {
|
||||
args := map[string]any{"page": 1, "query": "keep"}
|
||||
cloned := cloneToolArgs(args)
|
||||
args["page"] = 2
|
||||
args["extra"] = true
|
||||
|
||||
if got := cloned["page"]; got != 1 {
|
||||
t.Fatalf("cloned page = %#v, want 1", got)
|
||||
}
|
||||
if _, ok := cloned["extra"]; ok {
|
||||
t.Fatal("clone changed after source map mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCloneToolArgsEmpty(t *testing.T) {
|
||||
if got := cloneToolArgs(nil); got != nil {
|
||||
t.Fatalf("nil clone = %#v, want nil", got)
|
||||
}
|
||||
if got := cloneToolArgs(map[string]any{}); got != nil {
|
||||
t.Fatalf("empty clone = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("DWS_USAGE_TRACKING", "1")
|
||||
|
||||
inner := &crossPlatformCoverageCaller{}
|
||||
caller := newRecordingToolCaller(inner)
|
||||
args := map[string]any{"open_conversation_id": "cid_x", "text": "private"}
|
||||
if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner.args["open_conversation_id"] != "cid_x" {
|
||||
t.Fatalf("forwarded args = %#v", inner.args)
|
||||
}
|
||||
if caller.Format() != "json" || caller.Fields() != "id,name" || caller.JQ() != ".result" || caller.DryRun() {
|
||||
t.Fatal("recording caller did not delegate output settings")
|
||||
}
|
||||
records, err := usage.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 || records[0].Product != "chat" || records[0].Tool != "send_message" {
|
||||
t.Fatalf("usage records = %#v", records)
|
||||
}
|
||||
if _, leaked := records[0].SampleArgs["text"]; leaked {
|
||||
t.Fatal("sensitive text must not be recorded")
|
||||
}
|
||||
|
||||
inner.dryRun = true
|
||||
if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err = usage.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("dry-run call must not be recorded: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
root := NewRootCommand(context.Background())
|
||||
for _, path := range [][]string{{"shortcut", "list"}, {"calendar", "+today"}} {
|
||||
cmd, remaining, err := root.Find(path)
|
||||
if err != nil || len(remaining) != 0 || cmd == nil {
|
||||
t.Fatalf("root.Find(%v) = cmd=%v remaining=%v err=%v", path, cmd, remaining, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "audit", ".audit.lock")); !os.IsNotExist(err) {
|
||||
t.Fatalf("constructing the root command opened an audit lock: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFileSinkEmit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
defer sink.Close()
|
||||
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "abc123",
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "calendar",
|
||||
Command: "list_events",
|
||||
Endpoint: "https://api.example.com/mcp",
|
||||
Result: "success",
|
||||
DurationMs: 150,
|
||||
CLIVersion: "1.0.47",
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
}
|
||||
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if evt.Hash == "" {
|
||||
t.Error("expected hash to be set")
|
||||
}
|
||||
if evt.PrevHash != "" {
|
||||
t.Error("first event should have empty prev_hash")
|
||||
}
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded Event
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.ExecutionID != "abc123" {
|
||||
t.Errorf("got execution_id=%s, want abc123", decoded.ExecutionID)
|
||||
}
|
||||
if decoded.Hash == "" {
|
||||
t.Error("decoded hash should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainIntegrity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "exec-" + string(rune('a'+i)),
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "test",
|
||||
Command: "cmd",
|
||||
Result: "success",
|
||||
DurationMs: int64(i * 10),
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sink.Close()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid, brokenAt, err := VerifyFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Errorf("expected valid chain, broken at line %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainDetectsTampering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chain := NewChain(dir)
|
||||
sink := NewFileSink(writer, chain, nil)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
evt := &Event{
|
||||
Timestamp: time.Now(),
|
||||
ExecutionID: "exec-" + string(rune('0'+i)),
|
||||
Actor: Actor{UserID: "u1", CorpID: "c1"},
|
||||
Product: "test",
|
||||
Command: "cmd",
|
||||
Result: "success",
|
||||
DurationMs: 100,
|
||||
CLIVersion: "1.0.0",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
if err := sink.Emit(evt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sink.Close()
|
||||
|
||||
file, err := LatestAuditFile(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Tamper with the file: modify a character in the second line
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Find second newline and change a char after it
|
||||
lines := splitLines(data)
|
||||
if len(lines) < 2 {
|
||||
t.Fatal("expected at least 2 lines")
|
||||
}
|
||||
// Corrupt the second line by changing first char of product
|
||||
var evt2 map[string]any
|
||||
json.Unmarshal([]byte(lines[1]), &evt2)
|
||||
evt2["product"] = "tampered"
|
||||
tampered, _ := json.Marshal(evt2)
|
||||
lines[1] = string(tampered)
|
||||
|
||||
corrupted := []byte(lines[0] + "\n" + lines[1] + "\n" + lines[2] + "\n")
|
||||
os.WriteFile(file, corrupted, 0o600)
|
||||
|
||||
valid, brokenAt, _ := VerifyFile(file)
|
||||
if valid {
|
||||
t.Error("expected invalid chain after tampering")
|
||||
}
|
||||
if brokenAt != 2 {
|
||||
t.Errorf("expected break at line 2, got %d", brokenAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetention(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create old files
|
||||
oldDate := time.Now().AddDate(0, 0, -100).Format("20060102")
|
||||
recentDate := time.Now().AddDate(0, 0, -10).Format("20060102")
|
||||
os.WriteFile(filepath.Join(dir, "audit-"+oldDate+".jsonl"), []byte("old"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "audit-"+recentDate+".jsonl"), []byte("recent"), 0o600)
|
||||
|
||||
_, err := NewDateRotatingWriter(dir, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Give async pruning a moment
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "audit-"+oldDate+".jsonl")); !os.IsNotExist(err) {
|
||||
t.Error("expected old file to be pruned")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "audit-"+recentDate+".jsonl")); err != nil {
|
||||
t.Error("recent file should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactEvent(t *testing.T) {
|
||||
evt := Event{
|
||||
Actor: Actor{UserID: "uid123", Name: "张三", CorpID: "corp1", CorpName: "公司A"},
|
||||
Product: "calendar",
|
||||
Command: "list",
|
||||
ParamsSummary: `{"date":"2026-01-01"}`,
|
||||
Result: "success",
|
||||
}
|
||||
|
||||
hashed := RedactEvent(evt, RedactHashed)
|
||||
if hashed.Actor.Name == "张三" {
|
||||
t.Error("name should be hashed")
|
||||
}
|
||||
if hashed.ParamsSummary != "" {
|
||||
t.Error("params should be cleared in hashed mode")
|
||||
}
|
||||
if hashed.Actor.UserID != "uid123" {
|
||||
t.Error("user_id should remain in hashed mode")
|
||||
}
|
||||
|
||||
minimal := RedactEvent(evt, RedactMinimal)
|
||||
if minimal.Actor.UserID == "uid123" {
|
||||
t.Error("user_id should be hashed in minimal mode")
|
||||
}
|
||||
if minimal.Endpoint != "" {
|
||||
t.Error("endpoint should be cleared in minimal mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNopSink(t *testing.T) {
|
||||
var s NopSink
|
||||
if err := s.Emit(&Event{}); err != nil {
|
||||
t.Error("NopSink.Emit should not error")
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Error("NopSink.Close should not error")
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(data []byte) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == '\n' {
|
||||
if i > start {
|
||||
lines = append(lines, string(data[start:i]))
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
lines = append(lines, string(data[start:]))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Chain computes the L1 sha256 tamper-evidence chain. It is deliberately
|
||||
// stateless: every seal derives prev_hash from the last record already present
|
||||
// in the current day's file rather than from an in-memory or sidecar cursor.
|
||||
//
|
||||
// This makes the chain correct in two cases the previous global-sidecar design
|
||||
// broke:
|
||||
// - cross-date: each day rotates to a fresh file whose first record chains
|
||||
// from "", so every file is independently verifiable by VerifyFile.
|
||||
// - cross-process: because the caller holds an exclusive inter-process lock on
|
||||
// the file while sealing, the tail read reflects records written by any
|
||||
// other dws process, so concurrent writers cannot fork the chain.
|
||||
type Chain struct{}
|
||||
|
||||
// NewChain keeps the historical constructor signature. The directory argument
|
||||
// is no longer needed because prev_hash is derived from the target file.
|
||||
func NewChain(string) *Chain { return &Chain{} }
|
||||
|
||||
// SealFromFile reads the hash of the last record in f (the current day's audit
|
||||
// file) and returns the prev_hash / hash pair for the event whose hash-free
|
||||
// body is provided. The caller must hold the file lock.
|
||||
func (c *Chain) SealFromFile(f *os.File, body []byte) (prevHash, hash string, err error) {
|
||||
prevHash, err = lastRecordHash(f)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return prevHash, ComputeHash(prevHash, body), nil
|
||||
}
|
||||
|
||||
// lastRecordHash returns the "hash" field of the final non-empty JSONL record in
|
||||
// f, or "" when the file is empty. It reads only the tail of the file so cost
|
||||
// does not grow with file size.
|
||||
func lastRecordHash(f *os.File) (string, error) {
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
size := fi.Size()
|
||||
if size == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
const tailWindow = 64 * 1024
|
||||
start := size - tailWindow
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
buf := make([]byte, size-start)
|
||||
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Trim trailing newlines, then isolate the last line within the window.
|
||||
end := len(buf)
|
||||
for end > 0 && (buf[end-1] == '\n' || buf[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
if end == 0 {
|
||||
return "", nil
|
||||
}
|
||||
lineStart := end
|
||||
for lineStart > 0 && buf[lineStart-1] != '\n' {
|
||||
lineStart--
|
||||
}
|
||||
last := buf[lineStart:end]
|
||||
|
||||
var rec struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(last, &rec); err != nil {
|
||||
// The last record spilled past our tail window (pathologically large
|
||||
// line). Fall back to a full scan for correctness.
|
||||
if lineStart == 0 && start > 0 {
|
||||
return lastRecordHashFullScan(f)
|
||||
}
|
||||
return "", fmt.Errorf("audit: parse last record: %w", err)
|
||||
}
|
||||
return rec.Hash, nil
|
||||
}
|
||||
|
||||
func lastRecordHashFullScan(f *os.File) (string, error) {
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
|
||||
var last []byte
|
||||
for scanner.Scan() {
|
||||
if b := scanner.Bytes(); len(b) > 0 {
|
||||
last = append(last[:0], b...)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(last) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var rec struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(last, &rec); err != nil {
|
||||
return "", fmt.Errorf("audit: parse last record: %w", err)
|
||||
}
|
||||
return rec.Hash, nil
|
||||
}
|
||||
|
||||
func VerifyFile(path string) (valid bool, brokenAt int, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
|
||||
|
||||
prevHash := ""
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Bytes()
|
||||
|
||||
var evt struct {
|
||||
PrevHash string `json:"prev_hash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
return false, lineNum, fmt.Errorf("line %d: invalid JSON: %w", lineNum, err)
|
||||
}
|
||||
|
||||
if evt.PrevHash != prevHash {
|
||||
return false, lineNum, fmt.Errorf("line %d: prev_hash mismatch", lineNum)
|
||||
}
|
||||
|
||||
body := stripHashFields(line)
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prevHash))
|
||||
h.Write(body)
|
||||
expected := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
if evt.Hash != expected {
|
||||
return false, lineNum, fmt.Errorf("line %d: hash mismatch", lineNum)
|
||||
}
|
||||
|
||||
prevHash = evt.Hash
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false, lineNum, err
|
||||
}
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func stripHashFields(line []byte) []byte {
|
||||
var evt Event
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
return line
|
||||
}
|
||||
evt.PrevHash = ""
|
||||
evt.Hash = ""
|
||||
out, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ComputeHash(prevHash string, eventJSON []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prevHash))
|
||||
h.Write(eventJSON)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvAudit = "DWS_AUDIT"
|
||||
EnvAuditDir = "DWS_AUDIT_DIR"
|
||||
EnvRetentionDays = "DWS_AUDIT_RETENTION_DAYS"
|
||||
EnvForwardURL = "DWS_AUDIT_FORWARD_URL"
|
||||
EnvForwardToken = "DWS_AUDIT_FORWARD_TOKEN"
|
||||
EnvForwardRedact = "DWS_AUDIT_FORWARD_REDACT"
|
||||
EnvAuditDebug = "DWS_AUDIT_DEBUG"
|
||||
|
||||
defaultRetentionDays = 90
|
||||
auditSubdir = "audit"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAudit,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "操作审计日志开关(默认启用,设 0/false/off 关闭)",
|
||||
DefaultValue: "启用",
|
||||
Example: "0",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAuditDir,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计日志目录(默认 <configDir>/audit)",
|
||||
Example: "/var/log/dws-audit",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvRetentionDays,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计日志留存天数",
|
||||
DefaultValue: "90",
|
||||
Example: "180",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardURL,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "审计事件远端转发 URL(POST JSON)",
|
||||
Example: "https://siem.example.com/audit",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardToken,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "远端转发 Bearer Token",
|
||||
Sensitive: true,
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvForwardRedact,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "远端转发脱敏级别:none / hashed / minimal",
|
||||
DefaultValue: "none",
|
||||
Example: "hashed",
|
||||
})
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: EnvAuditDebug,
|
||||
Category: configmeta.CategoryAudit,
|
||||
Description: "打印审计子系统初始化/写入/转发失败诊断到 stderr(设 1/true/on 开启)",
|
||||
Example: "1",
|
||||
})
|
||||
}
|
||||
|
||||
// DebugEnabled reports whether audit-subsystem diagnostics should be surfaced to
|
||||
// stderr. Failures are always eligible for the structured log; this gates the
|
||||
// noisier stderr channel.
|
||||
func DebugEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvAuditDebug))) {
|
||||
case "1", "true", "on", "yes", "y":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsEnabled() bool {
|
||||
v := os.Getenv(EnvAudit)
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "0", "false", "off", "no", "n":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BuildSink constructs the audit sink for configDir. It returns an error when
|
||||
// the audit subsystem is enabled but cannot initialize (e.g. the log directory
|
||||
// is not writable) so the caller can surface the failure instead of silently
|
||||
// degrading. report receives non-fatal forwarder diagnostics; it may be nil.
|
||||
func BuildSink(configDir string, report func(format string, args ...any)) (Sink, error) {
|
||||
if !IsEnabled() {
|
||||
return NopSink{}, nil
|
||||
}
|
||||
|
||||
dir := os.Getenv(EnvAuditDir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join(configDir, auditSubdir)
|
||||
}
|
||||
|
||||
retention := defaultRetentionDays
|
||||
if v := os.Getenv(EnvRetentionDays); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
retention = n
|
||||
}
|
||||
}
|
||||
|
||||
writer, err := NewDateRotatingWriter(dir, retention)
|
||||
if err != nil {
|
||||
return NopSink{}, err
|
||||
}
|
||||
|
||||
chain := NewChain(dir)
|
||||
|
||||
var forwarder *HTTPForwarder
|
||||
if fwdURL := os.Getenv(EnvForwardURL); fwdURL != "" {
|
||||
token := os.Getenv(EnvForwardToken)
|
||||
redact := RedactLevel(strings.ToLower(os.Getenv(EnvForwardRedact)))
|
||||
if redact != RedactHashed && redact != RedactMinimal {
|
||||
redact = RedactNone
|
||||
}
|
||||
forwarder = NewHTTPForwarder(fwdURL, token, redact, report)
|
||||
}
|
||||
|
||||
return NewFileSink(writer, chain, forwarder), nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package audit
|
||||
|
||||
import "time"
|
||||
|
||||
type Event struct {
|
||||
Timestamp time.Time `json:"ts"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
Actor Actor `json:"actor"`
|
||||
Product string `json:"product"`
|
||||
Command string `json:"command"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ParamsSummary string `json:"params_summary,omitempty"`
|
||||
Result string `json:"result"`
|
||||
ErrCategory string `json:"error_category,omitempty"`
|
||||
ErrReason string `json:"error_reason,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CLIVersion string `json:"cli_version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
PrevHash string `json:"prev_hash"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Actor struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CorpID string `json:"corp_id"`
|
||||
CorpName string `json:"corp_name,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// lockFile takes a non-blocking exclusive advisory lock; returns an error when
|
||||
// another process holds it so the caller can retry with a timeout.
|
||||
func lockFile(f *os.File) error {
|
||||
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
}
|
||||
|
||||
func unlockFile(f *os.File) {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user