Compare commits

...
Author SHA1 Message Date
修雨 129e8a10ef Merge remote-tracking branch 'origin/main' into codex/fix-plugin-command-registration
# Conflicts:
#	CHANGELOG.md
2026-07-21 21:50:37 +08:00
修雨 02fba09c1e fix(plugin): let replaceable fallbacks pass distribution conflict checks
pluginDescriptorConflictsWithDistribution and the identity-owner seeding
both treated conference as distribution-owned, so the whole plugin server
was skipped before the replaceable-fallback merge in addPluginCommandsSafe
could run. Skip replaceablePluginFallbacks names in both early gates while
keeping reserved-command protection and plugin-vs-plugin ownership intact.
2026-07-21 21:49:47 +08:00
修雨 94b64f74ac Merge pull request #738 from typefield/fix/schema-cli-path-compat
fix(schema): accept compatible CLI path separators
2026-07-21 21:37:10 +08:00
玉澜 441289cdfe Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 20:50:37 +08:00
玉澜 d03823d772 test(schema): cover unknown compatibility query 2026-07-21 20:50:34 +08:00
修雨 c16a377863 Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:47:48 +08:00
修雨 31c3acc94b Merge pull request #718 from DingTalk-Real-AI/cleanup/remove-shortcut-eval-pii
chore: 移除含真实 PII 的 shortcut 评测产物
2026-07-21 20:47:19 +08:00
修雨 f7e702df8d Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:35:02 +08:00
修雨 7fd40ea19a Merge branch 'main' into cleanup/remove-shortcut-eval-pii 2026-07-21 20:34:48 +08:00
玉澜 bee246e62c Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 19:50:20 +08:00
玉澜 089caa92a8 test(schema): cover prefixed compatibility query 2026-07-21 19:41:39 +08:00
修雨 2f0f32f56f Merge pull request #739 from DingTalk-Real-AI/fix/release-artifact-raw-version-check
fix(release): verify packaged artifact versions from raw binary bytes
2026-07-21 19:25:39 +08:00
修雨 068d9ff2f5 fix(release): verify packaged artifact versions from raw binary bytes 2026-07-21 19:25:14 +08:00
玉澜 21cd3f8bc4 fix(schema): accept compatible CLI path separators 2026-07-21 19:18:07 +08:00
修雨 418928b9a5 Merge pull request #736 from DingTalk-Real-AI/chore/changelog-v1.0.53-stable
docs(changelog): finalize v1.0.53 stable section
2026-07-21 19:00:26 +08:00
修雨 b047b2c3c9 docs(changelog): fold post-beta.7 entries into v1.0.53 stable section 2026-07-21 18:59:56 +08:00
修雨 a516e5f54a Merge pull request #735 from sczheng189/codex/fix-stable-version-verification
fix(release): verify package versions from raw binaries
2026-07-21 18:55:54 +08:00
修雨 70e4e75c66 Merge branch 'main' into codex/fix-stable-version-verification 2026-07-21 18:55:31 +08:00
修雨 1116916b24 Merge pull request #734 from DingTalk-Real-AI/revert-732-fix/release-admission-commit-statuses
Revert "fix(release): check commit statuses in Code Admission gates"
2026-07-21 18:53:57 +08:00
修雨 c0c81b4d70 Merge pull request #733 from DingTalk-Real-AI/revert-730-codex/fix-release-version-verifier
Revert "fix(release): validate packaged version at runtime"
2026-07-21 18:53:53 +08:00
修雨 eedc41ac54 Merge branch 'main' into revert-730-codex/fix-release-version-verifier 2026-07-21 18:52:05 +08:00
zhengyubai c14e24569c fix(release): verify package versions from raw binaries 2026-07-21 19:49:18 +09:00
SCzheng 8add2c00cf Revert "fix(release): check commit statuses in Code Admission gates (#732)"
This reverts commit 29dceec5ce.
2026-07-21 19:48:47 +09:00
修雨 29dceec5ce fix(release): check commit statuses in Code Admission gates (#732)
The "AI Behavior" context is reported as a commit status (via
github.rest.repos.createCommitStatus) rather than a check run, but the
Code Admission gates only queried check runs via
github.rest.checks.listForRef. This caused every release to fail with
"missing: AI Behavior" since the context was never found.

Add a commit-status query after the check-run loop in both the preflight
and sealed-commit Code Admission gates. Statuses are merged only for
required contexts not already covered by a check run, preserving the
existing check-run precedence.
2026-07-21 18:48:03 +08:00
SCzheng b78a0dee47 Revert "fix(release): validate packaged version at runtime" 2026-07-21 19:46:32 +09:00
修雨 807191396e Merge pull request #730 from DingTalk-Real-AI/codex/fix-release-version-verifier
fix(release): validate packaged version at runtime
2026-07-21 18:12:40 +08:00
修雨 cce9b798d5 Merge remote-tracking branch 'origin/main' into codex/fix-release-version-verifier 2026-07-21 17:51:46 +08:00
修雨 bfa3a1bf33 Merge pull request #729 from sczheng189/feat/relax-stable-promotion-contract
feat(release): allow stable promotion with commits after the beta baseline
2026-07-21 17:47:53 +08:00
修雨 e154b4ecde fix(release): validate packaged version at runtime 2026-07-21 17:47:02 +08:00
zhengyubai f83c305749 feat(release): allow stable promotion with commits after the beta baseline
Stable releases previously required a byte-identical tree with the
promoted beta (only CHANGELOG.md could differ) and local releases had
to run exactly at the origin/main tip with an atomic main+tag push.
Together these froze main for the whole beta-to-stable window.

Relax both gates while keeping the beta soak mandatory:
- stable still requires an explicit delivered, non-withdrawn beta whose
  commit is an ancestor of the sealed release commit; the tree-identity
  drift check is removed
- local releases accept any clean sealed commit contained in
  origin/main history (any branch or detached HEAD) and push only the
  release tag; command-compatibility checks compare the sealed HEAD,
  matching CI
2026-07-21 18:35:59 +09:00
修雨 b898f5c987 Merge pull request #723 from DingTalk-Real-AI/codex/retry-npm-channel-verification
fix(release): wait for npm channel propagation
2026-07-21 15:56:48 +08:00
修雨 20750df20b fix(release): wait for npm channel propagation 2026-07-21 15:46:19 +08:00
修雨 749149b94a Merge pull request #721 from DingTalk-Real-AI/codex/release-v1.0.53
chore(release): prepare v1.0.53
2026-07-21 15:35:50 +08:00
修雨 e5c8ff9acd chore(release): prepare v1.0.53 2026-07-21 15:27:38 +08:00
修雨 706535b41e Merge pull request #717 from DingTalk-Real-AI/codex/fix-release-ref-fingerprint
fix(release): fingerprint allocated tag refs
2026-07-21 15:10:45 +08:00
DennisandClaude Opus 4.8 e15a2c4efb chore: remove shortcut eval artifacts containing real PII
These files were real-backend capture artifacts committed by mistake and
contain personal data — employee names/emails, mail subjects, conversation &
message IDs, contact userIds/org, and hardcoded real test-target IDs:

- docs/shortcut-real-read-results.json   (raw read responses)
- docs/shortcut-real-write-results.json  (raw write responses)
- docs/shortcut-comparison.html          (embeds the raw responses)
- scripts/run_shortcut_real_read_matrix.py (hardcoded real target IDs)

They are dev-only capture artifacts, not build/CI inputs — the checked-in
public_catalog_generated.go is committed and no workflow/Makefile references
them, so removal does not affect the build. The generator scripts under
scripts/ that read these JSONs are local dev tools; they should consume a
locally-provided, uncommitted capture instead.

Add .gitignore rules so these (and the untracked shortcut-gsb-eval.* variants)
can never be re-committed.

Note: this only removes them going forward. They remain in git history on
origin/main (commit 8687d68); scrubbing history requires a separate,
owner-approved filter-repo/force-push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:09:47 +08:00
修雨 9e88116a2d fix(release): fingerprint allocated tag refs 2026-07-21 14:55:11 +08:00
修雨 05a306148a Merge pull request #715 from DingTalk-Real-AI/codex/allow-optional-oss-mirror
fix(release): defer unprovisioned OSS mirror
2026-07-21 14:34:27 +08:00
修雨 0dcc796f4c fix(release): defer unprovisioned OSS mirror 2026-07-21 14:23:35 +08:00
SCzheng 3e792b1c86 Merge pull request #712 from PeterGuy326/codex/fix-local-release-cloud-seal-detection
fix(release): accept guarded local tag metadata
2026-07-21 12:48:59 +08:00
修雨 b9c822d49d fix(release): accept guarded local tag metadata 2026-07-21 12:24:57 +08:00
修雨 f9b9b83f48 Merge pull request #709 from DingTalk-Real-AI/codex/changelog-v1.0.53-beta.5
docs(changelog): seal v1.0.53-beta.5 notes
2026-07-21 11:50:21 +08:00
修雨 aa9e67e7c8 docs(changelog): seal v1.0.53-beta.5 notes 2026-07-21 11:41:58 +08:00
修雨 6c0cf3438b fix: address plugin review blockers 2026-07-21 11:33:03 +08:00
修雨 e3f30420fb fix: restore plugin overlay commands 2026-07-21 11:33:03 +08:00
修雨 16ff02903a Merge pull request #698 from wxianfeng/fix/event-token-lazy-resolution
fix(event): retry stream ticket once with rotated token after 401
2026-07-21 11:29:01 +08:00
修雨 65d3f2959c Merge branch 'main' into fix/event-token-lazy-resolution 2026-07-21 11:08:25 +08:00
修雨 cb3087ba9b Merge pull request #707 from DingTalk-Real-AI/codex/cloud-release-withdrawal
ci: add cloud-native releases and cross-platform withdrawal
2026-07-21 10:38:18 +08:00
上官玄 5068cfdab8 fix: preserve transient retry semantics on truncated responses 2026-07-21 10:34:52 +08:00
xuan 3c81e5d47d Merge branch 'main' into fix/event-token-lazy-resolution 2026-07-21 10:31:01 +08:00
修雨 d93925a892 Merge branch 'main' into codex/cloud-release-withdrawal 2026-07-21 10:28:17 +08:00
修雨 faab9e0282 Merge pull request #700 from DingTalk-Real-AI/codex/ci-test-contract
ci: enforce complete Go test coverage
2026-07-21 10:20:01 +08:00
修雨 76d301268d ci: add cloud release and withdrawal workflows 2026-07-21 10:03:25 +08:00
修雨 7fddace8df ci: enforce complete Go test coverage 2026-07-21 09:41:18 +08:00
shangguanxuan.sgx 99e5a3cceb test: rename 401-refresh tests into TestCrossPlatformCoverage so platform gates count them
The macOS/Windows coverage gates only execute tests matching
^(TestAllShortcuts|TestCrossPlatformCoverage), so the 401 refresh-retry
tests added for this change were invisible to them, leaving 12 changed
statements uncovered (92.73% < 100%). Rename the 12 existing tests into
the TestCrossPlatformCoverage prefix and add a fetchTicketAttempt edge
test covering transport failures, retryable statuses, and missing
endpoint/ticket payload fields.
2026-07-20 22:35:15 +08:00
shangguanxuan.sgx 7e31043875 Merge remote-tracking branch 'upstream/main' into fix/event-portal-401-retry 2026-07-20 21:20:25 +08:00
shangguanxuan.sgx 55d7fbf59a test: close coverage gate gaps on transient auth recovery paths
The Coverage gate flagged 16 uncovered changed statements (90.6% < 100%):

- drop the unreachable handler error / nil response branches in
  runPortalTicketAttempt: makeHandler never fails, matching the pre-port
  portal loop on main
- cover portalStageError nil Error/Unwrap, the reconnect min/max clamp,
  and the acked backoff reset via an end-to-end reconnect test
- cover personalRetryLogError fallback when a token failure carries no
  structured HTTP status
- cover ClassifyRefreshFailure nil/net.Error/redirect branches, the nil
  HTTPStatusError message, and oauthExchangeDisplayError fallback
- cover the personal stream source ForceRefreshToken wiring end to end

Local gate now reports changed code coverage 100.0% (165 statements).
2026-07-20 21:00:21 +08:00
zhengyubai c0f4d21c05 fix(event): keep long-running sources alive across transient auth failures
Ported from 342d44efe (backup/event-token-lazy-resolution-pre-rewrite) and
adapted to the current in-place single 401 refresh+retry design:

- portal source: classify ticket/dial/read/ack failures via portalStageError
  and reconnect with backoff on retryable stages only (DisableReconnect for
  tests and one-shot callers); stage errors never leak response bodies
- personal/portal: transient token provider or refresh failures (network,
  408/429/5xx) go through the reconnect loop instead of killing the source;
  terminal failures (400/401/403) remain fatal
- personalRetryLogError: token resolution/refresh errors log only the
  structured HTTP status, never provider error details

Unlike the original commit, a rejected token is still retried once in place
after a successful refresh, and a second 401 stays fatal (single-refresh
guard agreed in review).
2026-07-20 18:37:23 +08:00
zhengyubai 660c908585 fix(auth): classify refresh failures and keep transient ones recoverable
Restored from the pre-rewrite branch head 342d44efe (backed up as
backup/event-token-lazy-resolution-pre-rewrite); the auth-layer changes
apply verbatim on the rebased branch.

- Add ClassifyRefreshFailure with structured HTTPStatusError so refresh
  failures split into transient (network, timeout, 408/429/5xx) and
  terminal (400/401/403) classes; unknown errors stay fatal.
- GetTokenSnapshot no longer marks a profile expired on transient
  refresh failures, so long-running sources can retry after backoff.
- postJSON returns HTTPStatusError keeping the response body out of the
  error string; the OAuth callback page HTML-escapes the sanitized
  exchange error instead of echoing raw server output.
- isInvalidGrantError also matches the preserved response body.
2026-07-20 18:09:15 +08:00
shangguanxuan.sgx 377ebc5e85 fix(event): classify personal ticket errors by status before reading body
A 401 whose error body failed mid-read (e.g. unexpected EOF) was wrapped
as retryable by the body-read path, letting the outer reconnect loop
re-enter fetchTicket and refresh again on every iteration, bypassing the
single refresh-retry guard.

Classify non-2xx responses by status first; the body is only drained
best-effort since it is never used for error reporting here. 401 stays
fatal regardless of body state, while 2xx body-read failures remain
retryable transport errors.
2026-07-20 17:57:27 +08:00
修雨 076d77da8e Merge pull request #699 from DingTalk-Real-AI/codex/ci-coverage-100
ci: shorten workflow name and require 100% changed-code coverage
2026-07-20 17:44:56 +08:00
shangguanxuan.sgx 2eca203e74 fix(event): retry stream ticket once with rotated token after 401
Portal and personal ticket requests now perform a single controlled
refresh + retry inside the production chain when the server rejects the
resolved access token with HTTP 401:

- Add optional ForceRefreshToken callback to PortalTicketConfig and
  PersonalConfig. It receives the exact rejected token so the app-level
  compare-and-refresh (ForceRefreshRejectedToken) can dedupe concurrent
  rotations, and returns the fresh token.
- requestPortalTicket / fetchTicket retry the ticket request once with
  the rotated token directly instead of surfacing an error and hoping an
  outer loop retries; a second 401 stays fatal to prevent refresh loops.
- Refresh failures keep both the original 401 and the refresh error via
  errors.Join; empty rotated tokens fail fast before hitting the server.
- Wire forceRefreshRejectedAccessToken into event consume (portal) and
  personal stream sources; resolveSourceAccessToken strict semantics are
  unchanged (provider errors still propagate, no static-token fallback).
- Tests: full DingtalkSource.Start -> startPortalTicket chain
  (401 -> refresh -> ticket ok -> WebSocket event), rotated-token reuse,
  refresh failure, nil-callback compatibility, second-401 fatality, and
  app-level wiring.
2026-07-20 17:39:33 +08:00
修雨 6e070a7e24 ci: tighten PR coverage gate 2026-07-20 17:14:35 +08:00
修雨 e867abd03c Merge pull request #687 from shangguanxuan633-lab/codex/auth-token-manager-complete
fix(auth): unify token resolution and recover rejected tokens
2026-07-20 15:07:07 +08:00
修雨 9d89965de9 Merge branch 'main' into codex/auth-token-manager-complete 2026-07-20 14:53:06 +08:00
修雨 41088bb965 fix(release): derive OSS_REGION for ossutil v2 V4 signing (#692)
ossutil 2.x signs requests with V4 and refuses to run without an
explicit region, so the OSS mirror sync would fail in CI even with
valid credentials. Derive OSS_REGION from the endpoint host
(including -internal variants) and fail fast when it cannot be
derived.
2026-07-20 14:51:41 +08:00
修雨 8259116f15 test(auth): isolate Windows keychain packages 2026-07-20 14:33:17 +08:00
上官玄 9ec1fa0638 test(auth): use synthetic log redaction sentinel 2026-07-20 14:22:18 +08:00
修雨 9afd3be79b Merge branch 'main' into codex/auth-token-manager-complete 2026-07-20 14:15:48 +08:00
修雨 67da5019e3 Merge pull request #689 from DingTalk-Real-AI/codex/fix-beta4-channel-repair
fix(release): recover immutable mirror channels safely
2026-07-20 14:07:54 +08:00
shangguanxuan.sgx b0ded7deb8 fix(auth): retry rejected access tokens safely 2026-07-20 13:37:18 +08:00
shangguanxuan.sgx 22905fc41e fix(auth): unify access token resolution 2026-07-20 12:17:04 +08:00
142 changed files with 14046 additions and 33378 deletions
+1
View File
@@ -95,6 +95,7 @@ jobs:
typeof filename === 'string' &&
(
filename.startsWith('.github/workflows/') ||
filename.startsWith('scripts/ci/') ||
filename.startsWith('scripts/policy/') ||
filename.startsWith('scripts/release/') ||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
+26 -32
View File
@@ -1,4 +1,4 @@
name: Code Admission — PR 合入门禁
name: CI
on:
push:
@@ -130,11 +130,13 @@ jobs:
with:
go-version-file: go.mod
- name: Verify Test Package Plan
if: steps.classify.outputs.changelog_only != 'true'
run: make test-plan
- name: Format Check
if: steps.classify.outputs.changelog_only != 'true'
run: |
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
run: make format-check
- name: Go Vet
if: steps.classify.outputs.changelog_only != 'true'
@@ -167,38 +169,20 @@ jobs:
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
if: ${{ matrix.shard == 'remaining' }}
run: make build
- name: Test shard with Race Detection
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
TEST_SHARD: ${{ matrix.shard }}
run: |
case "$TEST_SHARD" in
app)
packages=(./internal/app/...)
;;
generators)
packages=(./internal/generator/...)
;;
helpers)
packages=(./internal/helpers/...)
;;
remaining)
mapfile -t packages < <(
go list ./cmd/... ./internal/... |
grep -Ev '/internal/(app|generator|helpers)(/|$)'
)
;;
*)
printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2
exit 1
;;
esac
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -v -race -count=1 -timeout=10m "${packages[@]}"
@@ -207,7 +191,7 @@ jobs:
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -221,7 +205,16 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Test release scripts
run: go test -v -count=1 -timeout=5m ./test/scripts
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list release-scripts)"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -v -count=1 -timeout=10m "${packages[@]}"
test-cross-platform:
name: Test (cross-platform compile)
@@ -714,9 +707,10 @@ jobs:
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true'
env:
COVERAGE_TARGET: "80"
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
COVERAGE_OVERALL_TOLERANCE: "0"
run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
- name: Generate coverage report
if: needs.lint.outputs.changelog_only != 'true'
+1 -1
View File
@@ -3,7 +3,7 @@ name: Main Integration — Wukong Overlay
on:
workflow_run:
workflows:
- Code Admission — PR 合入门禁
- CI
types:
- completed
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
name: Withdraw release
run-name: Withdraw ${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)"
required: true
type: string
reason:
description: "Public, single-line withdrawal reason (8-300 characters)"
required: true
type: string
confirmation:
description: "Type WITHDRAW followed by a space and the exact version"
required: true
type: string
permissions:
contents: read
# Share the publication lock with release.yml. A withdrawal and a publication
# must never mutate channel pointers concurrently.
concurrency:
group: dws-release-publication
cancel-in-progress: false
jobs:
withdraw:
name: Withdraw release from every distribution channel
environment: release-withdrawal
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
actions: read
contents: write
steps:
- name: Verify withdrawal environment protection
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/environments/{environment_name}",
{ owner, repo, environment_name: "release-withdrawal" },
);
const reviewerRule = response.data.protection_rules.find(
(rule) => rule.type === "required_reviewers",
);
if (
!reviewerRule ||
reviewerRule.prevent_self_review !== true ||
!Array.isArray(reviewerRule.reviewers) ||
reviewerRule.reviewers.length === 0
) {
core.setFailed("release-withdrawal must require a reviewer and prevent self-review");
return;
}
if (response.data.deployment_branch_policy?.protected_branches !== true) {
core.setFailed("release-withdrawal must allow only protected branches");
}
if (response.data.can_admins_bypass !== false) {
core.setFailed("release-withdrawal must not allow administrator bypass");
}
- name: Require the exact current official default-branch commit
uses: actions/github-script@v7
with:
script: |
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
const defaultBranch = context.payload.repository.default_branch;
if (context.eventName !== "workflow_dispatch") {
core.setFailed("release withdrawal accepts workflow_dispatch only");
return;
}
if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) {
core.setFailed(`release withdrawal is restricted to ${expectedRepository}`);
return;
}
if (context.ref !== `refs/heads/${defaultBranch}`) {
core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`);
return;
}
const branch = await github.rest.git.getRef({
...context.repo,
ref: `heads/${defaultBranch}`,
});
if (branch.data.object.sha !== context.sha) {
core.setFailed(
`default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`,
);
}
- name: Check out trusted withdrawal tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for npm channel withdrawal
uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- name: Withdraw immutable release and roll back channels
id: withdrawal
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
WITHDRAW_VERSION: ${{ inputs.version }}
WITHDRAW_REASON: ${{ inputs.reason }}
WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }}
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
run: |
./scripts/release/withdraw-release.sh \
"$WITHDRAW_VERSION" \
"$WITHDRAW_REASON" \
"$WITHDRAW_CONFIRMATION"
- name: Report withdrawal boundary
if: ${{ always() }}
env:
VERSION: ${{ inputs.version }}
RESULT: ${{ steps.withdrawal.outcome }}
run: |
{
echo "### Release withdrawal: ${VERSION}"
echo
echo "- Workflow result: ${RESULT}"
echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier."
echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause."
echo "- The problem GitHub Release and original tag are removed after npm and every tag-enabled/configured mirror are rolled back, so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed."
echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded."
echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun."
} >> "$GITHUB_STEP_SUMMARY"
+8
View File
@@ -54,3 +54,11 @@ test/dev_functional/results.jsonl
/coverage-policy.txt
/coverage.html
dwsbin
# Local shortcut eval / real-backend capture artifacts — may contain real PII
# (employee names/emails, userIds, conversation & message IDs). Never commit.
/docs/shortcut-real-read-results.json
/docs/shortcut-real-write-results.json
/docs/shortcut-comparison.html
/docs/shortcut-gsb-eval.*
/scripts/run_shortcut_real_read_matrix.py
+56
View File
@@ -6,6 +6,60 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Fixed
- **Schema CLI path compatibility** — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
- **Plugin CLI overlays** — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
## [1.0.53] - 2026-07-21
This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path.
### Added
- **Enterprise and office command coverage** — adds enterprise creation, employee invitation, and account provisioning commands; 366 declarative service shortcuts; Sheet import commands; and Aitable workflow create/update support with reviewed Schema contracts.
- **Multiple accounts in one DingTalk organization** — profiles can distinguish accounts by organization and user, select them explicitly, and log out one account or an entire organization without overwriting another account's credentials.
- **Expanded personal IM event subscriptions** (#651) — adds read-receipt, recall, and reaction events for one-to-one and group chats, plus specified-sender subscriptions by staff ID or OpenDingTalk ID.
- **Official multi-platform Homebrew channel** — ships separate stable and keg-only beta Formulae for macOS and Linux across amd64 and arm64, with isolated update PRs.
### Changed
- **Personal event output contract** (#651) — `event consume` now emits event-specific top-level structured fields; scripts that consumed the former transport envelope must use the flat fields or select `-f raw`, while `--debug-raw-events` retains the diagnostic envelope.
- **Guarded release lifecycle** — beta/stable publication now uses explicit promotion, immutable delivery proofs, protected recovery, and tag-bound optional OSS policy; an unprovisioned OSS mirror is sealed as `deferred` so GitHub, npm, and Homebrew are not blocked.
- **Relaxed stable promotion contract** (#729) — a stable release still requires a delivered, non-withdrawn beta baseline in its commit history, but no longer requires a byte-identical tree with that beta; reviewed commits merged to `main` after the beta can now ship in the stable release. Local releases now accept any sealed commit contained in `main` history and push only the release tag, so `main` is never frozen during the beta-to-stable window.
### Fixed
- **Authentication and credential reliability** — organization-policy denials stop before mutation or polling, long-running clients reload and refresh access tokens consistently, concurrent credential writes are atomic, and Windows portable-auth commands fail before reading or writing unsupported credential bundles.
- **Command validation and compatibility** — invalid Sheet/task targets fail locally, IM shortcuts preserve AI-tag and alias compatibility, and Aitable import uploads require and forward a positive file size.
- **Release publication reliability** — GitHub draft publication is bound to one verified release ID and exact assets, preflight uses isolated installer worktrees, guarded local tags remain compatible, cloud planning fingerprints the actual allocated release refs, and npm channel verification waits for bounded registry propagation without moving tags.
- **Package-manager version verification** (#735) — npm-vendored, Homebrew-installed, and packaged release binaries are now verified by searching their raw bytes for the injected version marker, so a correctly versioned stable binary is no longer rejected when the short version marker coalesces with adjacent printable linker metadata; incorrect or missing markers still fail closed.
## [1.0.53-beta.7] - 2026-07-21
This beta validates bounded npm channel verification after registry publication.
### Fixed
- **npm dist-tag eventual consistency** — Release delivery now tolerates a briefly stale `latest` or `beta` read after publishing by retrying only when npm reports a valid older version. Registry errors, invalid or incomparable tags, and channels that never converge still fail closed without moving any tag during verification.
## [1.0.53-beta.6] - 2026-07-21
This beta validates guarded local release compatibility and tag-bound OSS deferral so an unprovisioned mirror cannot block the primary release channels.
### Changed
- **Tag-bound optional OSS release mirror** — Official cloud Release runs no longer block GitHub, npm, and Homebrew delivery when an OSS bucket has not been provisioned. Cloud tags immutably record `OSS-Mirror: enabled|deferred`; publication, repair, and withdrawal consume that sealed policy instead of the current repository variable. Enabled releases remain fail-closed, while deferred releases skip the nonexistent channel and cannot be backfilled without a future audited repair proof.
### Fixed
- **Guarded local release compatibility** — The tag-push Release workflow now accepts the `Channel`-only annotated tags created by the guarded local release entry while continuing to reject any partial cloud-only seal metadata.
- **Cloud release tag allocation fingerprint** — Release planning now fingerprints the actual `v*` and `withdrawn/v*` refs fetched from GitHub, matching the seal job's API view instead of hashing an empty non-wildcard ref prefix and rejecting every publish before tag creation.
## [1.0.53-beta.5] - 2026-07-21
This beta validates long-running access-token recovery and the faster, recoverable guarded release path introduced after v1.0.53-beta.4.
### Changed
- **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated.
@@ -13,6 +67,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
### Fixed
- **Long-running event authentication recovery** — personal and portal event streams resolve the current access token for every ticket request, refresh a server-rejected token with compare-and-refresh semantics, and reconnect with backoff when refresh is temporarily blocked by network failures, rate limits, or 5xx responses.
- **Consistent access-token caching and errors** — runtime, recovery, Skill, PAT polling, and personal/portal event clients now resolve user access tokens through one expiry- and publication-aware manager, so long-running processes reload rotated credentials while keychain, refresh, parse, permission, and cancellation failures remain observable instead of being collapsed into “not authenticated.”
- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery.
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
+1 -1
View File
@@ -36,8 +36,8 @@ Common repository checks already used here include:
./scripts/policy/check-open-source-assets.sh
go test ./...
make test
make test-plan
make lint
bash test/scripts/run_all_tests.sh --jobs 8
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/release/verify-package-managers.sh
+30 -7
View File
@@ -1,12 +1,14 @@
GO ?= go
DWS_PACKAGE_VERSION ?= 0.0.0-test
REMOTE ?=
PUBLISH ?= 0
YES ?= 0
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -14,13 +16,15 @@ help:
@printf "Available targets:\n"
@printf " make build - Build the dws CLI binary\n"
@printf " make test - Run the Go test suite\n"
@printf " make lint - Run formatting checks and golangci-lint when available\n"
@printf " make fmt - Format Go source files\n"
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<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 coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@@ -45,13 +49,32 @@ rebuild:
@./scripts/dev/build.sh
test:
@./test/scripts/run_all_tests.sh --timeout 5m
@DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./...
test-plan:
@./scripts/ci/test-packages.sh verify
lint:
@./scripts/dev/lint.sh
format-check:
@set -eu; \
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
$(GO_SOURCE_LIST) > "$$go_files"; \
unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \
if [ -n "$$unformatted" ]; then \
printf '%s\n' "$$unformatted"; \
printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \
exit 1; \
fi
fmt:
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
@set -eu; \
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
policy:
@mkdir -p "$(POLICY_GOTMPDIR)"
+1 -1
View File
@@ -52,7 +52,7 @@ run.
```mermaid
flowchart TB
PR["Pull request"] --> CA["Code Admission — PR 合入门禁"]
PR["Pull request"] --> CA["CI"]
subgraph CA_CHECKS["Nine required contexts"]
L["Lint"]
T["Test"]
+27 -15
View File
@@ -108,21 +108,33 @@ commit, and failed tag-push run all match; it then reuses the normal release
jobs. Do not put publication secrets in temporary branches or create ad-hoc
recovery workflows.
If the immutable GitHub Release and npm package were delivered but a downstream
China mirror failed, dispatch the normal `Release` workflow from the protected
default branch with exactly one of `repair_gitee_version` or
`repair_oss_version`. Channel repair accepts a failed exact-tag run only when
its latest attempt completed the release contract, build, Apple signature,
immutable GitHub publication, and npm delivery checks for the exact tagged
commit. It then downloads and re-verifies the immutable assets before invoking
only the selected mirror. An OSS repair requires the OSS step itself to be the
recorded failure. A Gitee repair accepts either a failed Gitee job or a Gitee
job that was skipped behind that OSS failure; the latter is an explicit Gitee
backfill and does not claim that OSS has been repaired. Gitee repair requires
`GITEE_TOKEN`, `GITEE_USER`, and `GITEE_REPO`; OSS repair requires
`OSS_ACCESS_KEY_ID`, `OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET`
(with optional `OSS_PREFIX`) as Actions secrets. Missing credentials fail the
selected repair closed.
Cloud-sealed releases mirror to OSS only when the repository variable
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
is provisioned; GitHub, npm, and Homebrew delivery can then complete without
running the OSS step. Once enabled, missing credentials, an invalid Bucket, or
an upload failure remains fail-closed. The cloud tag immutably records the
decision as `OSS-Mirror: enabled|deferred`; publication and withdrawal consume
that sealed value instead of the variable's later state. Deferred releases
cannot use `repair_oss_version`; enabling OSS applies to later release tags
until an audited immutable repair marker is implemented.
If an immutable GitHub Release and npm package were delivered but an enabled
downstream China mirror failed, dispatch the normal `Release` workflow from the
protected default branch with exactly one of `repair_gitee_version` or
`repair_oss_version`. Channel repair accepts a fully successful exact release,
or a failed exact-tag run only when its latest attempt completed the release
contract, build, Apple signature, immutable GitHub publication, and npm
delivery checks for the exact tagged commit. OSS repair additionally requires
the tag's sealed policy to be `enabled`. It then downloads and re-verifies the
immutable assets before invoking only the selected mirror. For a failed
release, an OSS repair requires the OSS step itself to be the recorded failure.
A Gitee repair accepts either a failed Gitee job or a Gitee job that was
skipped behind that OSS failure; the latter is an explicit Gitee backfill and
does not claim that OSS has been repaired. Gitee repair requires `GITEE_TOKEN`,
`GITEE_USER`, and `GITEE_REPO`; OSS repair requires `OSS_ACCESS_KEY_ID`,
`OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET` (with optional
`OSS_PREFIX`) as Actions secrets. Missing credentials fail the selected repair
closed.
## Handoff Checklist
+11 -5
View File
@@ -1,4 +1,4 @@
# Code Admission — PR 合入门禁
# CI — PR 合入门禁
The pull-request admission layer has exactly nine required external contexts:
@@ -6,7 +6,7 @@ The pull-request admission layer has exactly nine required external contexts:
|---|---|
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
| `Coverage` | Overall non-regression and changed-code coverage |
| `Coverage` | Overall non-regression and 100% changed-code coverage |
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
| `Edition` | Edition contract tests |
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
@@ -14,7 +14,7 @@ The pull-request admission layer has exactly nine required external contexts:
| `CLI Smoke` | Offline help for every public top-level command |
| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests |
The workflow display name is `Code Admission — PR 合入门禁`. Parallel helper
The workflow display name is `CI`. Parallel helper
jobs may implement `Test` and `Coverage`, but they are not ruleset contexts.
Do not require an aggregate alias or a downstream integration check in place of
the nine contracts above.
@@ -81,7 +81,7 @@ successful PR check.
```mermaid
flowchart TB
PR["Pull request"] --> ADMISSION["Code Admission — PR 合入门禁"]
PR["Pull request"] --> ADMISSION["CI"]
ADMISSION --> L["Lint"]
ADMISSION --> T["Test"]
ADMISSION --> C["Coverage"]
@@ -122,7 +122,13 @@ base_ref=$(git merge-base HEAD origin/main)
`make coverage-gate` is an enforcement step, not a profile generator. CI
generates the candidate, supporting, merge-base, and (when risk-selected)
native profiles before the aggregate `Coverage` context evaluates them.
native profiles before the aggregate `Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
non-regression check. Candidate and baseline profiles are evaluated by the
same block-deduplicating checker; supporting policy and shortcut profiles
contribute to changed-code coverage only. The checked-in badge is presentation
only and is never read as a gate input.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
+73 -21
View File
@@ -1,10 +1,54 @@
# 发布手册(预发 / 正式)
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `Code Admission — PR 合入门禁` 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
## 日常只用一个入口
## 推荐入口:GitHub 云端发布
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
## 自动版本规则
- beta:如果存在尚未封正式版的最高版本线,自动取 `beta.N+1`;否则从最新已分配正式版按所选 patch/minor/major 开新线并取 `beta.1`。
- stable:先锁定最高开放版本线上的最新已分配 beta,再要求它已成功交付且未撤回;不会跳过失败/撤回的最新 beta 去选择更早版本。正式版 core 与该 beta 完全相同。
- `vX.Y.Z`、`vX.Y.Z-beta.N` 一经分配就永久占用。撤回时创建 `withdrawn/v...` 墓碑,原编号永不复用。
- 例如撤回 `v1.0.53-beta.5` 后,下一 beta 是 `v1.0.53-beta.6`;撤回正式版 `v1.0.53` 后,下一 patch 修复线是 `v1.0.54-beta.1`,验证后再发布 `v1.0.54`。
- 如果最新 beta 已撤回,禁止直接用更早 beta 晋级正式版;必须先构建下一个 beta。
## 全平台撤回与回滚
已公开版本出现问题时,在 GitHub Actions 运行 `Withdraw release`,分支必须选择当前默认分支 `main`,并填写:
- `version`:精确版本,例如 `v1.0.53` 或 `v1.0.53-beta.5`。
- `reason`:8–300 字符的单行公开原因。
- `confirmation`:精确输入 `WITHDRAW <version>`,例如 `WITHDRAW v1.0.53`。
该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行:
1. 先创建永久 annotated tag `withdrawn/<version>`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。
2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。
3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;只有目标 tag 封存了 `OSS-Mirror: enabled` 时,OSS 才会先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。
4. npm 以及目标 tag 启用或发布时配置的镜像渠道均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。
GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。
撤回前必须存在同一渠道中更早、完整交付且未撤回的安全版本;若目标是该渠道第一个版本、没有安全候选,workflow 会在创建墓碑或修改任何渠道前 fail closed,需要先决定明确的替代策略。CLI 本地 rollback 也只有在本机仍保留上一次升级备份时可用。
撤回以“精确版本”为单位,不会因为正式版曾由某个 beta 晋级就隐式级联修改另一个渠道。若同一缺陷同时存在于正式版及其 beta,应先撤回正式版,再撤回对应 beta,并分别使用各自的精确确认串;每次都只会把该渠道回退到自己的安全候选。
撤回正式版 `v1.0.53` 后,`v1.0.53` 仍被墓碑视为已分配。下一次 patch 发布从 `v1.0.54-beta.1` 开始,验证后晋级 `v1.0.54`。撤回 `v1.0.53-beta.5` 后,同一开放版本线继续为 `v1.0.53-beta.6`;不会退回或复用 `beta.5`。
## 兼容入口:本地发布
安装发布 Skill 后直接运行:
@@ -25,11 +69,11 @@ dws-release config --remote origin
```text
main 上的候选代码 + beta CHANGELOG
→ vX.Y.Z-beta.N(预发验证)
→ 只允许补正式 CHANGELOG,源码不得再变化
→ vX.Y.Z(正式发布)
→ 补正式 CHANGELOG;允许继续通过 PR 合入新 commit
→ vX.Y.Z(正式发布,封板提交必须包含该 beta 提交)
```
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线要求该 beta 已成功交付、未撤回,且 beta 提交必须位于正式发布封板提交的历史中——不能跳过 beta 直接发正式版,但允许在 beta 之后把经过 review 合入 `main` 的 commit 一起发布。
## 预发发布
@@ -68,7 +112,7 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
```
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
## CHANGELOG 契约
@@ -86,20 +130,20 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
OSS/Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
已启用的 OSS 或 Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel
repair 会精确绑定失败 tag run 的最新 attempt;contract、构建、Developer ID 签名、
repair 会精确绑定失败 tag run 的最新 attempt,且 OSS repair 要求 tag 的 sealed policy 为 `enabled`;contract、构建、Developer ID 签名、
immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败,
随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step;
Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功,
@@ -110,25 +154,27 @@ workflow 和本地直发脚本已停用,避免绕开 publication queue 或用
## 既有 tag 的紧急恢复
tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
云端封板或本地 tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
```bash
dws-release recover v1.2.3-beta.1
```
命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
命令会自动解析 annotated tag object、peeled commit,以及 tag 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
## 平台治理前置
@@ -136,8 +182,14 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type tokenManagerSnapshotProvider struct {
load func() (*authpkg.TokenData, error)
}
func (p tokenManagerSnapshotProvider) GetAccessToken(context.Context) (string, error) {
data, err := p.load()
if err != nil || data == nil {
return "", err
}
return data.AccessToken, nil
}
func (p tokenManagerSnapshotProvider) GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) {
return p.load()
}
type tokenManagerLegacyGetter struct {
token string
err error
}
func (g tokenManagerLegacyGetter) GetToken() (string, string, error) {
return g.token, "file", g.err
}
func installTokenManagerFakes(t *testing.T, load func() (*authpkg.TokenData, error)) {
t.Helper()
oldProvider, oldLegacy := newAccessTokenProvider, newLegacyTokenManager
oldEdition := edition.Get()
edition.Override(&edition.Hooks{})
newAccessTokenProvider = func(string) accessTokenGetter {
return tokenManagerSnapshotProvider{load: load}
}
newLegacyTokenManager = func(string) legacyTokenGetter {
return tokenManagerLegacyGetter{err: authpkg.ErrTokenDataNotFound}
}
t.Cleanup(func() {
newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldLegacy
edition.Override(oldEdition)
})
}
func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
token := "token-a"
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
first, err := manager.Get(context.Background(), configDir, "")
if err != nil || first.AccessToken != "token-a" {
t.Fatalf("first token = %#v, %v", first, err)
}
second, err := manager.Get(context.Background(), configDir, "")
if err != nil || second.AccessToken != "token-a" || calls.Load() != 1 {
t.Fatalf("cached token = %#v, %v, calls=%d", second, err, calls.Load())
}
token = "token-b"
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
rotated, err := manager.Get(context.Background(), configDir, "")
if err != nil || rotated.AccessToken != "token-b" || calls.Load() != 2 {
t.Fatalf("rotated token = %#v, %v, calls=%d", rotated, err, calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerDoesNotCacheWithoutExpiryOrRevision(t *testing.T) {
configDir := t.TempDir()
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token"}, nil
})
manager := NewTokenManager()
for range 2 {
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerTreatsMalformedMarkerAsUncacheable(t *testing.T) {
configDir := t.TempDir()
if err := os.WriteFile(filepath.Join(configDir, "token.json"), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
for range 2 {
if snapshot, err := manager.Get(context.Background(), configDir, ""); err != nil || snapshot.AccessToken != "token" {
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerDoesNotCacheOpaqueEditionStorageWithProviderFallback(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
edition.Override(&edition.Hooks{
LoadToken: func(string) ([]byte, error) { return nil, nil },
TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
return fallback()
},
})
manager := NewTokenManager()
for range 2 {
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
t.Fatal(err)
}
}
if calls.Load() != 2 {
t.Fatalf("provider calls = %d, want 2", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerCoalescesConcurrentLoads(t *testing.T) {
configDir := t.TempDir()
if err := authpkg.WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
var calls atomic.Int32
release := make(chan struct{})
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
calls.Add(1)
<-release
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
})
manager := NewTokenManager()
const workers = 8
var wg sync.WaitGroup
wg.Add(workers)
errs := make(chan error, workers)
for range workers {
go func() {
defer wg.Done()
_, err := manager.Get(context.Background(), configDir, "")
errs <- err
}()
}
for calls.Load() == 0 {
time.Sleep(time.Millisecond)
}
close(release)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if calls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", calls.Load())
}
}
func TestCrossPlatformCoverageTokenManagerPreservesProviderFailure(t *testing.T) {
configDir := t.TempDir()
want := errors.New("keychain permission denied")
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { return nil, want })
_, err := NewTokenManager().Get(context.Background(), configDir, "")
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
}
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
t.Fatalf("provider failure was misclassified as missing credentials: %v", err)
}
}
func TestCrossPlatformCoverageTokenResolutionErrorOnlyClassifiesTrueMissingCredential(t *testing.T) {
missing := tokenResolutionError(authpkg.ErrTokenDataNotFound)
var typed interface{ Unwrap() error }
if !errors.As(missing, &typed) || !errors.Is(missing, authpkg.ErrTokenDataNotFound) {
t.Fatalf("missing error = %v", missing)
}
want := errors.New("decrypt failed")
if got := tokenResolutionError(want); !errors.Is(got, want) || errors.Is(got, authpkg.ErrTokenDataNotFound) {
t.Fatalf("storage error = %v", got)
}
if got := tokenResolutionError(context.Canceled); !errors.Is(got, context.Canceled) {
t.Fatalf("cancellation = %v", got)
}
}
+244 -48
View File
@@ -21,19 +21,61 @@ import (
"log/slog"
"path/filepath"
"strings"
"sync"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const accessTokenRefreshWindow = 5 * time.Minute
type legacyTokenGetter interface {
GetToken() (string, string, error)
}
type accessTokenSnapshotGetter interface {
GetTokenSnapshot(context.Context) (*authpkg.TokenData, error)
}
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
// Refresh-token material never leaves the auth package.
type AccessTokenSnapshot struct {
AccessToken string
ExpiresAt time.Time
Source string
}
type tokenManagerKey struct {
configDir string
profile string
}
type tokenManagerEntry struct {
mu sync.Mutex
snapshot AccessTokenSnapshot
revision string
}
// TokenManager is the only process cache for user access tokens. Cache entries
// are isolated by config directory and profile, expiry-aware, and invalidated
// by the credential publication marker written by auth storage.
type TokenManager struct {
mu sync.Mutex
entries map[tokenManagerKey]*tokenManagerEntry
now func() time.Time
}
func NewTokenManager() *TokenManager {
return &TokenManager{entries: make(map[tokenManagerKey]*tokenManagerEntry), now: time.Now}
}
var runtimeTokenManager = NewTokenManager()
var (
newAccessTokenProvider = func(configDir string) accessTokenGetter {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, discard)
configureOAuthProviderCompatibility(provider, configDir)
return provider
}
@@ -44,64 +86,218 @@ var (
}
)
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
// the same host compatibility hooks as MCP. It mirrors the former body of
// getCachedRuntimeToken (excluding process-level cache and timing).
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
provider := newAccessTokenProvider(configDir)
token, tokenErr := provider.GetAccessToken(ctx)
if tokenErr == nil && strings.TrimSpace(token) != "" {
return strings.TrimSpace(token), nil
}
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
return "", tokenErr
}
if strings.TrimSpace(authpkg.RuntimeProfile()) != "" {
if tokenErr != nil {
return "", tokenErr
}
return "", nil
}
manager := newLegacyTokenManager(configDir)
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
return strings.TrimSpace(leg), nil
}
if tokenErr != nil {
return "", tokenErr
}
return "", nil
}
// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should
// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches
// the active edition config directory, the same process-cached path as MCP is used.
// Otherwise tokens are loaded from configDir with host compatibility hooks applied.
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
if t := strings.TrimSpace(explicitToken); t != "" {
return t, nil
// Get resolves an access token for the active runtime profile.
func (m *TokenManager) Get(ctx context.Context, configDir, explicitToken string) (AccessTokenSnapshot, error) {
if token := strings.TrimSpace(explicitToken); token != "" {
return AccessTokenSnapshot{AccessToken: token, Source: "explicit"}, nil
}
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
return AccessTokenSnapshot{}, fmt.Errorf("config directory is empty")
}
if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) {
if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" {
return tok, nil
key := tokenManagerKey{
configDir: canonicalTokenConfigDir(configDir),
profile: strings.TrimSpace(authpkg.RuntimeProfile()),
}
entry := m.entry(key)
entry.mu.Lock()
defer entry.mu.Unlock()
now := time.Now()
if m != nil && m.now != nil {
now = m.now()
}
revision, present, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
if tokenSnapshotUsable(entry.snapshot, now) && present && revision != "" && revision == entry.revision {
return entry.snapshot, nil
}
// Treat the marker and credential as one optimistic snapshot. A concurrent
// login/refresh between the reads causes a retry instead of caching stale A
// under the publication marker for B.
for attempt := 0; attempt < 4; attempt++ {
beforeRevision, beforePresent, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
return "", noCredentialsError()
snapshot, err := resolveTokenSnapshotWithEdition(ctx, configDir, key.profile)
if err != nil {
return AccessTokenSnapshot{}, err
}
afterRevision, afterPresent, err := authpkg.ReadTokenMarkerRevision(configDir)
if err != nil {
return AccessTokenSnapshot{}, err
}
if beforePresent != afterPresent || beforeRevision != afterRevision {
continue
}
if strings.TrimSpace(snapshot.AccessToken) == "" {
return AccessTokenSnapshot{}, noCredentialsError()
}
if tokenSnapshotUsable(snapshot, now) && afterPresent && afterRevision != "" {
entry.snapshot = snapshot
entry.revision = afterRevision
} else {
entry.snapshot = AccessTokenSnapshot{}
entry.revision = ""
}
return snapshot, nil
}
tok, err := resolveAccessTokenFromDir(ctx, configDir)
return AccessTokenSnapshot{}, fmt.Errorf("token publication changed repeatedly while resolving credentials")
}
func (m *TokenManager) entry(key tokenManagerKey) *tokenManagerEntry {
m.mu.Lock()
defer m.mu.Unlock()
if m.entries == nil {
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
}
entry := m.entries[key]
if entry == nil {
entry = &tokenManagerEntry{}
m.entries[key] = entry
}
return entry
}
func (m *TokenManager) Invalidate() {
if m == nil {
return
}
m.mu.Lock()
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
m.mu.Unlock()
}
func resolveTokenSnapshotWithEdition(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
hooks := edition.Get()
opaqueStorage := hooks.LoadToken != nil || hooks.SaveToken != nil || hooks.DeleteToken != nil
provider := hooks.TokenProvider
if provider == nil {
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
if err != nil {
return AccessTokenSnapshot{}, err
}
// Opaque edition storage hooks have no publication-revision contract.
// Resolve them on every logical request instead of caching a token that
// may be replaced outside the default auth store.
if opaqueStorage {
snapshot.ExpiresAt = time.Time{}
}
return snapshot, nil
}
var fallbackSnapshot AccessTokenSnapshot
var fallbackCalled bool
token, err := provider(ctx, func() (string, error) {
fallbackCalled = true
var fallbackErr error
fallbackSnapshot, fallbackErr = resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
if fallbackErr != nil {
return "", fallbackErr
}
return fallbackSnapshot.AccessToken, nil
})
if err != nil {
return AccessTokenSnapshot{}, fmt.Errorf("edition token provider: %w", err)
}
token = strings.TrimSpace(token)
if token == "" {
return AccessTokenSnapshot{}, noCredentialsError()
}
if fallbackCalled && token == fallbackSnapshot.AccessToken {
if opaqueStorage {
fallbackSnapshot.ExpiresAt = time.Time{}
}
return fallbackSnapshot, nil
}
// Edition providers expose no lifetime metadata, so resolve them on every
// logical request instead of recreating a process-lifetime string cache.
return AccessTokenSnapshot{AccessToken: token, Source: "edition"}, nil
}
func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
provider := newAccessTokenProvider(configDir)
if snapshotProvider, ok := provider.(accessTokenSnapshotGetter); ok {
data, err := snapshotProvider.GetTokenSnapshot(ctx)
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
return AccessTokenSnapshot{
AccessToken: strings.TrimSpace(data.AccessToken),
ExpiresAt: data.ExpiresAt,
Source: "oauth",
}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if strings.TrimSpace(profile) != "" {
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
return resolveLegacyToken(configDir, err)
}
token, err := provider.GetAccessToken(ctx)
if err == nil && strings.TrimSpace(token) != "" {
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: "oauth_compat"}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if strings.TrimSpace(profile) != "" {
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
return resolveLegacyToken(configDir, err)
}
func resolveLegacyToken(configDir string, oauthErr error) (AccessTokenSnapshot, error) {
token, source, err := newLegacyTokenManager(configDir).GetToken()
if err == nil && strings.TrimSpace(token) != "" {
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: source}, nil
}
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return AccessTokenSnapshot{}, err
}
if oauthErr != nil {
return AccessTokenSnapshot{}, oauthErr
}
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
}
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, authpkg.RuntimeProfile())
if err != nil {
return "", err
}
if tok != "" {
return tok, nil
return snapshot.AccessToken, nil
}
// ResolveAuxiliaryAccessToken resolves every non-runner bearer token through
// the same TokenManager used by MCP tool calls.
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
snapshot, err := runtimeTokenManager.Get(ctx, configDir, explicitToken)
if err != nil {
return "", err
}
return "", noCredentialsError()
return snapshot.AccessToken, nil
}
func tokenSnapshotUsable(snapshot AccessTokenSnapshot, now time.Time) bool {
return strings.TrimSpace(snapshot.AccessToken) != "" &&
!snapshot.ExpiresAt.IsZero() &&
now.Before(snapshot.ExpiresAt.Add(-accessTokenRefreshWindow))
}
func canonicalTokenConfigDir(configDir string) string {
if absolute, err := filepath.Abs(configDir); err == nil {
return filepath.Clean(absolute)
}
return filepath.Clean(configDir)
}
func noCredentialsError() error {
if edition.Get().IsEmbedded {
return fmt.Errorf("认证信息已失效,请重新认证")
return fmt.Errorf("认证信息已失效,请重新认证: %w", authpkg.ErrTokenDataNotFound)
}
return fmt.Errorf("no credentials found, run: dws auth login")
return fmt.Errorf("no credentials found, run: dws auth login: %w", authpkg.ErrTokenDataNotFound)
}
+16 -8
View File
@@ -34,6 +34,10 @@ func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) {
return g.token, g.err
}
func (g fakeAccessTokenGetter) ForceRefreshRejectedToken(context.Context, string) (string, error) {
return g.token, g.err
}
type fakeLegacyTokenGetter struct {
token string
err error
@@ -212,14 +216,16 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
t.Fatalf("decryption error = %v", err)
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} }
newAccessTokenProvider = func(string) accessTokenGetter {
return fakeAccessTokenGetter{err: authpkg.ErrTokenDataNotFound}
}
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
t.Fatalf("legacy token = %q, %v", got, err)
}
authpkg.SetRuntimeProfile("corp:user")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || err == nil || err.Error() != "missing" {
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err)
}
authpkg.SetRuntimeProfile("")
@@ -246,10 +252,10 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {
oldMark, oldFactory := markAccessTokenStale, newRefreshProvider
oldLoad, oldFactory := loadRefreshTokenData, newRefreshProvider
oldStop := stopStdio
t.Cleanup(func() {
markAccessTokenStale, newRefreshProvider = oldMark, oldFactory
loadRefreshTokenData, newRefreshProvider = oldLoad, oldFactory
stopStdio = oldStop
stdioMu.Lock()
stdioClients = make(map[string]*transport.StdioClient)
@@ -257,11 +263,13 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
})
fail := errors.New("failure")
_ = oldFactory(t.TempDir())
markAccessTokenStale = func(string) error { return fail }
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { return nil, fail }
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
t.Fatalf("mark stale error = %v", err)
t.Fatalf("load rejected token error = %v", err)
}
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "rejected"}, nil
}
markAccessTokenStale = func(string) error { return nil }
for _, tc := range []struct {
getter fakeAccessTokenGetter
want string
@@ -270,7 +278,7 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
{getter: fakeAccessTokenGetter{token: " refreshed "}},
} {
newRefreshProvider = func(string) accessTokenGetter { return tc.getter }
newRefreshProvider = func(string) rejectedAccessTokenRefresher { return tc.getter }
got, err := ForceRefreshAccessToken(context.Background(), "config")
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
t.Fatalf("refresh error = %v, want %q", err, tc.want)
+136
View File
@@ -15,6 +15,15 @@ package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
)
// authRetryingKey marks a context that has already attempted one
@@ -23,8 +32,35 @@ import (
// to the user instead.
type authRetryingKeyType struct{}
type authRefreshFailureError struct {
rejection error
refresh error
}
func (e *authRefreshFailureError) Error() string {
return "automatic access token refresh failed"
}
func (e *authRefreshFailureError) Unwrap() []error {
if e == nil {
return nil
}
return []error{e.rejection, e.refresh}
}
var authRetryingKey = authRetryingKeyType{}
var (
runnerForceRefreshRejectedAccessToken = forceRefreshRejectedAccessToken
runnerExecuteAuthRetry func(*runtimeRunner, context.Context, string, executor.Invocation) (executor.Result, error)
)
func init() {
runnerExecuteAuthRetry = func(r *runtimeRunner, ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
return r.executeInvocation(ctx, endpoint, invocation)
}
}
// IsAuthRetrying reports whether the current context is already inside an
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
func IsAuthRetrying(ctx context.Context) bool {
@@ -34,3 +70,103 @@ func IsAuthRetrying(ctx context.Context) bool {
v, _ := ctx.Value(authRetryingKey).(bool)
return v
}
func withAuthRetrying(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, authRetryingKey, true)
}
func authRefreshLogger() *slog.Logger {
if logger := FileLoggerInstance(); logger != nil {
return logger
}
return slog.Default()
}
func (r *runtimeRunner) managesRuntimeOAuth(hasPluginAuth bool) bool {
if r == nil || hasPluginAuth {
return false
}
return r.globalFlags == nil || strings.TrimSpace(r.globalFlags.Token) == ""
}
// retryAuthRefreshRequired consumes only the explicit edition marker. It does
// not infer retryability from free text, generic auth categories, HTTP 403, or
// ordinary business errors.
func (r *runtimeRunner) retryAuthRefreshRequired(
ctx context.Context,
endpoint string,
invocation executor.Invocation,
rejectedAccessToken string,
markerErr error,
hasPluginAuth bool,
) (executor.Result, error, bool) {
marker, marked := authretry.As(markerErr)
if !marked {
return executor.Result{}, nil, false
}
cause := marker.Cause
if cause == nil {
cause = markerErr
}
// Explicit --token and plugin credentials are not backed by the default
// OAuth refresh store. Preserve the overlay cause without mutating an
// unrelated persisted login.
if !r.managesRuntimeOAuth(hasPluginAuth) {
return executor.Result{}, cause, true
}
if IsAuthRetrying(ctx) {
authRefreshLogger().Warn("auth.runtime.refresh.retry_exhausted",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
)
return executor.Result{}, cause, true
}
if _, err := runnerForceRefreshRejectedAccessToken(ctx, defaultConfigDir(), rejectedAccessToken); err != nil {
// Keep every log credential-safe. The returned error chain retains the
// complete cause for in-process diagnosis; even DWS_DEBUG_AUTH must not
// serialize an OAuth response body or other attacker-controlled text.
authRefreshLogger().Warn("auth.runtime.refresh.failed",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
"stage", "force_refresh_rejected_token",
"error_type", fmt.Sprintf("%T", err),
)
logging.AuthDebug("auth.runtime.refresh.failed.detail",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
"stage", "force_refresh_rejected_token",
"error_type", fmt.Sprintf("%T", err),
)
combined := &authRefreshFailureError{rejection: cause, refresh: err}
return executor.Result{}, apperrors.NewAuth(
"automatic access token refresh failed",
apperrors.WithOperation("auth/token/refresh"),
apperrors.WithReason("auth_refresh_failed"),
apperrors.WithHint("本地凭证已保留;可稍后重试,若持续失败请查看认证诊断日志。"),
apperrors.WithCause(combined),
), true
}
logging.AuthDebug("auth.runtime.refresh.succeeded",
"product", invocation.CanonicalProduct,
"tool", invocation.Tool,
)
result, err := runnerExecuteAuthRetry(r, withAuthRetrying(ctx), endpoint, invocation)
return result, err, true
}
// isRefreshableTransportAuthError deliberately excludes HTTP/RPC 403 and
// generic CategoryAuth values. OnAuthError may request a refresh only for an
// exact transport-level unauthorized signal.
func isRefreshableTransportAuthError(err error) bool {
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
return false
}
return typed.Reason == "http_401" || typed.RPCCode == 401
}
@@ -0,0 +1,354 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func installAuthRefreshRunnerSeams(t *testing.T) {
t.Helper()
previousHooks := edition.Get()
previousCall := runnerCallTool
previousPreflight := runnerPreflightDocDownload
previousRefresh := runnerForceRefreshRejectedAccessToken
previousRetry := runnerExecuteAuthRetry
previousCapture := runnerCaptureRuntimeFailure
previousProfile := authpkg.RuntimeProfile()
pluginAuthMu.Lock()
previousPlugins := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
}
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
authpkg.SetRuntimeProfile("")
runtimeTokenManager.Invalidate()
t.Setenv("DWS_CONFIG_DIR", "")
t.Setenv("DWS_DEBUG_AUTH", "0")
t.Cleanup(func() {
edition.Override(previousHooks)
runnerCallTool = previousCall
runnerPreflightDocDownload = previousPreflight
runnerForceRefreshRejectedAccessToken = previousRefresh
runnerExecuteAuthRetry = previousRetry
runnerCaptureRuntimeFailure = previousCapture
authpkg.SetRuntimeProfile(previousProfile)
runtimeTokenManager.Invalidate()
pluginAuthMu.Lock()
pluginAuthRegistry = previousPlugins
pluginAuthMu.Unlock()
})
}
func authRefreshTestRunner(flags *GlobalFlags) *runtimeRunner {
return &runtimeRunner{
transport: transport.NewClient(nil),
globalFlags: flags,
auditSink: audit.NopSink{},
}
}
func authRefreshTestInvocation() executor.Invocation {
return executor.Invocation{
CanonicalProduct: "auth-retry-test-product",
Tool: "test_tool",
Params: map[string]any{"value": "safe"},
}
}
func authRefreshTokenHooks(configDir string, token *string, classify func(map[string]any) error) *edition.Hooks {
return &edition.Hooks{
ConfigDir: func() string { return configDir },
TokenProvider: func(context.Context, func() (string, error)) (string, error) {
return *token, nil
},
ClassifyToolResult: classify,
}
}
func TestCrossPlatformCoverageRunnerRetriesEditionAuthMarkerOnce(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
if expired, _ := content["expired"].(bool); expired {
return &authretry.AuthRefreshRequired{Cause: rejection}
}
return nil
}))
var callTokens []string
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
callTokens = append(callTokens, client.AuthToken)
if len(callTokens) == 1 {
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
}
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(_ context.Context, gotDir, rejected string) (string, error) {
refreshCalls++
if gotDir != configDir || rejected != "old-access" {
t.Fatalf("refresh input = dir %q token %q", gotDir, rejected)
}
token = "new-access"
return token, nil
}
result, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if err != nil {
t.Fatal(err)
}
if refreshCalls != 1 || len(callTokens) != 2 || callTokens[0] != "old-access" || callTokens[1] != "new-access" {
t.Fatalf("refreshes=%d call tokens=%v", refreshCalls, callTokens)
}
content, _ := result.Response["content"].(map[string]any)
if content["value"] != "ok" || content["success"] != true {
t.Fatalf("result content = %#v", content)
}
}
func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog(t *testing.T) {
installAuthRefreshRunnerSeams(t)
t.Setenv("DWS_DEBUG_AUTH", "1")
configDir := t.TempDir()
token := "old-access"
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
edition.Override(authRefreshTokenHooks(configDir, &token, func(map[string]any) error {
return &authretry.AuthRefreshRequired{Cause: rejection}
}))
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
}
refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"uid-secret-value"}`)
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
return "", refreshErr
}
var logs bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previousLogger) })
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, rejection) || !errors.Is(err, refreshErr) {
t.Fatalf("error = %v, want rejection and refresh causes", err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth || typed.Reason != "auth_refresh_failed" || typed.Operation != "auth/token/refresh" {
t.Fatalf("refresh envelope = %#v", typed)
}
var rendered bytes.Buffer
if printErr := apperrors.PrintJSON(&rendered, err); printErr != nil {
t.Fatal(printErr)
}
for _, want := range []string{`"category": "auth"`, `"reason": "auth_refresh_failed"`, `"operation": "auth/token/refresh"`} {
if !strings.Contains(rendered.String(), want) {
t.Fatalf("structured stderr missing %s: %s", want, rendered.String())
}
}
for _, secret := range []string{"access-token-secret", "refresh-token-secret", "uid-secret-value"} {
if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) {
t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String())
}
}
for _, want := range []string{"auth.runtime.refresh.failed", "auth.runtime.refresh.failed.detail", "force_refresh_rejected_token", "error_type"} {
if !strings.Contains(logs.String(), want) {
t.Fatalf("safe refresh log missing %q: %s", want, logs.String())
}
}
}
func TestCrossPlatformCoverageRunnerSecondEditionMarkerReturnsSecondCause(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
firstCause := errors.New("first rejection")
secondCause := errors.New("second rejection")
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
attempt, _ := content["attempt"].(int)
if attempt == 1 {
return &authretry.AuthRefreshRequired{Cause: firstCause}
}
return &authretry.AuthRefreshRequired{Cause: secondCause}
}))
calls := 0
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
calls++
return transport.ToolCallResult{Content: map[string]any{"attempt": calls}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
token = "new-access"
return token, nil
}
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, secondCause) || errors.Is(err, firstCause) {
t.Fatalf("error = %v, want only second rejection cause", err)
}
if calls != 2 || refreshCalls != 1 {
t.Fatalf("calls=%d refreshes=%d", calls, refreshCalls)
}
}
func TestCrossPlatformCoverageRunnerOnAuthErrorOnlyRetriesExactUnauthorized(t *testing.T) {
t.Run("http 401 marker retries once", func(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
rejection := errors.New("transport rejected token")
hookCalls := 0
hooks := authRefreshTokenHooks(configDir, &token, nil)
hooks.OnAuthError = func(string, error) error {
hookCalls++
return &authretry.AuthRefreshRequired{Cause: rejection}
}
edition.Override(hooks)
calls := 0
var callTokens []string
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
calls++
callTokens = append(callTokens, client.AuthToken)
if calls == 1 {
return transport.ToolCallResult{}, apperrors.NewAuth("unauthorized", apperrors.WithReason("http_401"))
}
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
token = "new-access"
return token, nil
}
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
t.Fatal(err)
}
if hookCalls != 1 || refreshCalls != 1 || calls != 2 || strings.Join(callTokens, ",") != "old-access,new-access" {
t.Fatalf("hook=%d refresh=%d calls=%d tokens=%v", hookCalls, refreshCalls, calls, callTokens)
}
})
for _, tc := range []struct {
name string
err error
}{
{name: "http 403", err: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"))},
{name: "ordinary auth", err: apperrors.NewAuth("load failed", apperrors.WithReason("auth_load_failed"))},
} {
t.Run(tc.name+" does not enter hook", func(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
hookCalls := 0
hooks := authRefreshTokenHooks(configDir, &token, nil)
hooks.OnAuthError = func(string, error) error {
hookCalls++
return &authretry.AuthRefreshRequired{Cause: errors.New("must not run")}
}
edition.Override(hooks)
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, tc.err
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
return "", nil
}
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, tc.err) || hookCalls != 0 || refreshCalls != 0 {
t.Fatalf("error=%v hook=%d refresh=%d", err, hookCalls, refreshCalls)
}
})
}
}
func TestCrossPlatformCoverageRunnerDoesNotRefreshExplicitTokenMarker(t *testing.T) {
installAuthRefreshRunnerSeams(t)
rejection := errors.New("explicit token rejected")
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
return &authretry.AuthRefreshRequired{Cause: rejection}
}})
calls := 0
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
calls++
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
return "", nil
}
_, err := authRefreshTestRunner(&GlobalFlags{Token: "explicit-token"}).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
if !errors.Is(err, rejection) || calls != 1 || refreshCalls != 0 {
t.Fatalf("error=%v calls=%d refresh=%d", err, calls, refreshCalls)
}
}
func TestCrossPlatformCoverageRunnerRetriesPreflightEditionMarkerOnce(t *testing.T) {
installAuthRefreshRunnerSeams(t)
configDir := t.TempDir()
token := "old-access"
rejection := errors.New("preflight token rejected")
edition.Override(authRefreshTokenHooks(configDir, &token, nil))
preflightCalls := 0
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
preflightCalls++
if preflightCalls == 1 {
return &authretry.AuthRefreshRequired{Cause: rejection}
}
return nil
}
toolCalls := 0
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
toolCalls++
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
}
refreshCalls := 0
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
refreshCalls++
token = "new-access"
return token, nil
}
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
t.Fatal(err)
}
if preflightCalls != 2 || toolCalls != 1 || refreshCalls != 1 {
t.Fatalf("preflights=%d tools=%d refreshes=%d", preflightCalls, toolCalls, refreshCalls)
}
}
+9
View File
@@ -49,6 +49,15 @@ func RegisterPluginAuth(productID string, auth *PluginAuth) {
pluginAuthRegistry[productID] = auth
}
// ClearPluginAuth removes credentials for a plugin product. Registration uses
// this before applying an accepted descriptor so a descriptor without custom
// auth cannot inherit stale credentials from an earlier root construction.
func ClearPluginAuth(productID string) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
delete(pluginAuthRegistry, productID)
}
// LookupPluginAuth returns the authentication credentials registered
// for the given product ID, or nil if none exists.
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
+3 -1
View File
@@ -554,7 +554,7 @@ func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
defer server.Close()
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
t.Cleanup(func() { SetDynamicServers(nil) })
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client())}
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
t.Fatalf("recovery search = %#v %v", got, err)
@@ -1650,6 +1650,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
}
func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T) {
authpkg.SetRuntimeProfile("")
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour),
CorpID: "corp", UserID: "user", ClientID: "client",
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -164,7 +165,7 @@ func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) {
}
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} }
missing := t.TempDir()
if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" {
if got, err := resolveAccessTokenFromDir(context.Background(), missing); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
t.Fatalf("missing access token = %q, %v", got, err)
}
if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
+9 -11
View File
@@ -56,6 +56,7 @@ var (
eventNewEventSource = newEventSource
eventNewDingtalkSource = source.New
eventResolveAccessToken = ResolveAuxiliaryAccessToken
eventForceRefreshRejected = forceRefreshRejectedAccessToken
eventBusRun = bus.Run
eventReadyFDFromEnv = busctl.ReadyFDFromEnv
eventResolvePersonal = resolvePersonalEventIdentity
@@ -413,7 +414,7 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
return "portal-ticket-normal:" + sourceID
}
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
func newEventSource(_ context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
if !streamOpts.enabled() {
return eventNewDingtalkSource(source.Config{
ClientID: clientID,
@@ -421,14 +422,6 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
})
}
token, err := eventResolveAccessToken(ctx, configDir, "")
if err != nil {
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("event stream ticket: empty user token")
}
portalClientID := clientID
portalClientSecret := clientSecret
if streamOpts.usesPortalNormalMode() {
@@ -440,8 +433,13 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
ClientID: portalClientID,
ClientSecret: portalClientSecret,
PortalTicket: &source.PortalTicketConfig{
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
AccessToken: token,
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
AccessTokenProvider: func(ctx context.Context) (string, error) {
return eventResolveAccessToken(ctx, configDir, "")
},
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
return eventForceRefreshRejected(ctx, configDir, rejectedToken)
},
SourceID: eventStreamSourceID(streamOpts.SourceID),
Mode: streamOpts.Mode,
ClientID: portalClientID,
@@ -132,14 +132,18 @@ func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) {
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
t.Fatal(err)
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
stream := eventStreamTicketOptions{Mode: "custom"}
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) {
t.Fatalf("stream token error = %v", err)
var captured source.Config
eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) {
captured = cfg
return &source.DingtalkSource{}, nil
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil }
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil {
t.Fatal("empty stream token succeeded")
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err != nil {
t.Fatalf("stream source construction = %v", err)
}
if _, err := captured.PortalTicket.AccessTokenProvider(context.Background()); !errors.Is(err, fail) {
t.Fatalf("stream token provider error = %v", err)
}
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
for _, mode := range []string{"custom", "normal"} {
@@ -0,0 +1,47 @@
package app
import (
"context"
"errors"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
)
// TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken asserts the portal ticket
// source receives a ForceRefreshToken callback that forwards the actual
// rejected token into the app-level compare-and-refresh chain.
func TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken(t *testing.T) {
oldNew, oldRefresh := eventNewDingtalkSource, eventForceRefreshRejected
t.Cleanup(func() { eventNewDingtalkSource, eventForceRefreshRejected = oldNew, oldRefresh })
var captured source.Config
eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) {
captured = cfg
return &source.DingtalkSource{}, nil
}
var gotDir, gotRejected string
eventForceRefreshRejected = func(_ context.Context, configDir, rejectedToken string) (string, error) {
gotDir, gotRejected = configDir, rejectedToken
return "fresh", nil
}
if _, err := newEventSource(context.Background(), "config-dir", "client", "secret", eventStreamTicketOptions{Mode: "custom"}); err != nil {
t.Fatal(err)
}
if captured.PortalTicket == nil || captured.PortalTicket.ForceRefreshToken == nil {
t.Fatal("ForceRefreshToken not wired into portal ticket config")
}
tok, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "rejected-token")
if err != nil || tok != "fresh" {
t.Fatalf("force refresh = %q, %v", tok, err)
}
if gotDir != "config-dir" || gotRejected != "rejected-token" {
t.Fatalf("wiring passed dir %q rejected %q", gotDir, gotRejected)
}
fail := errors.New("refresh failed")
eventForceRefreshRejected = func(context.Context, string, string) (string, error) { return "", fail }
if _, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "x"); !errors.Is(err, fail) {
t.Fatalf("refresh error = %v", err)
}
}
+23 -5
View File
@@ -131,6 +131,7 @@ var (
personalFindProcess = os.FindProcess
personalSignalProcess = (*os.Process).Signal
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken
personalLoadTokenData = authpkg.LoadTokenData
personalClientID = authpkg.ClientID
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
@@ -259,7 +260,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return personalConsumeRun(ctx, cfg)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
@@ -498,7 +499,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
if status == "" || status == "all" {
status = ""
}
subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
@@ -613,7 +614,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
for _, id := range subscribeIDs {
if err := personalDeleteSubscription(client, ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
@@ -723,7 +724,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
if err != nil {
return personal.Identity{}, err
}
tokenData, _ := personalLoadTokenData(configDir)
tokenData, err := personalLoadTokenData(configDir)
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
}
var corpID, userID, clientID, refreshToken string
if tokenData != nil {
corpID = tokenData.CorpID
@@ -769,6 +773,15 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
}, nil
}
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
return client
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
@@ -817,7 +830,12 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessToken: opts.Identity.AccessToken,
AccessTokenProvider: func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
},
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken)
},
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
@@ -0,0 +1,54 @@
package app
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
)
// TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken asserts the
// personal stream source receives a ForceRefreshToken callback that forwards
// the rejected token into the app-level compare-and-refresh chain.
func TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken(t *testing.T) {
oldAux := personalResolveAuxiliaryAccessToken
oldRefresh := personalForceRefreshRejectedToken
t.Cleanup(func() {
personalResolveAuxiliaryAccessToken = oldAux
personalForceRefreshRejectedToken = oldRefresh
})
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
return "old-token", nil
}
refreshErr := errors.New("refresh rejected")
var gotDir, gotRejected string
personalForceRefreshRejectedToken = func(_ context.Context, configDir, rejectedToken string) (string, error) {
gotDir, gotRejected = configDir, rejectedToken
return "", refreshErr
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{
ConfigDir: "config-dir",
Identity: personal.Identity{ClientID: "client", SourceID: "source"},
TicketURL: srv.URL,
})
if err != nil {
t.Fatal(err)
}
// The 401 ticket response routes the rejected token through the wired
// ForceRefreshToken; the unknown refresh failure stays fatal.
if err := src.Start(context.Background(), func(*dwsevent.RawEvent) {}); !errors.Is(err, refreshErr) {
t.Fatalf("Start() error = %v, want wrapped refresh error", err)
}
if gotDir != "config-dir" || gotRejected != "old-token" {
t.Fatalf("refresh wiring got dir %q rejected %q", gotDir, gotRejected)
}
}
+27 -16
View File
@@ -27,9 +27,13 @@ type accessTokenGetter interface {
GetAccessToken(context.Context) (string, error)
}
type rejectedAccessTokenRefresher interface {
ForceRefreshRejectedToken(context.Context, string) (string, error)
}
var (
markAccessTokenStale = authpkg.MarkAccessTokenStale
newRefreshProvider = func(configDir string) accessTokenGetter {
loadRefreshTokenData = authpkg.LoadTokenData
newRefreshProvider = func(configDir string) rejectedAccessTokenRefresher {
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
provider := authpkg.NewOAuthProvider(configDir, disc)
configureOAuthProviderCompatibility(provider, configDir)
@@ -42,26 +46,33 @@ var (
// server-side rejection (HTTP 401 or business code such as
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
//
// Steps:
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
// OAuthProvider.GetAccessToken's fast-path will miss.
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
// existing dual-layer lock (process + file) to serialize concurrent
// refresh attempts across goroutines and processes.
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
// next resolveAuthToken call re-reads from disk.
//
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
// is the only entry point that orchestrates "force refresh" semantics.
// It snapshots the current access token, then delegates to the OAuth
// provider's dual-locked compare-and-refresh operation. If another caller has
// already rotated the token, that newer token is reused without another
// refresh request.
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
if err := markAccessTokenStale(configDir); err != nil {
return "", fmt.Errorf("mark access token stale: %w", err)
data, err := loadRefreshTokenData(configDir)
if err != nil {
return "", err
}
if data == nil || strings.TrimSpace(data.AccessToken) == "" {
return "", fmt.Errorf("stored access token is empty")
}
return forceRefreshRejectedAccessToken(ctx, configDir, data.AccessToken)
}
func forceRefreshRejectedAccessToken(ctx context.Context, configDir, rejectedAccessToken string) (string, error) {
if strings.TrimSpace(configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
if strings.TrimSpace(rejectedAccessToken) == "" {
return "", fmt.Errorf("rejected access token is empty")
}
provider := newRefreshProvider(configDir)
tok, err := provider.GetAccessToken(ctx)
tok, err := provider.ForceRefreshRejectedToken(ctx, rejectedAccessToken)
if err != nil {
return "", err
}
+10 -4
View File
@@ -27,21 +27,27 @@ import (
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
// built-ins. Conflicts with built-ins are skipped inside Load.
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
if loadUserShortcuts {
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
}
}
// Built-in + user shortcuts (`dws <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()...)
if loadUserShortcuts {
commands = append(commands, builtin.Commands()...)
} else {
commands = append(commands, builtin.BaseCommands()...)
}
return mergeTopLevelCommands(commands)
}
+20 -20
View File
@@ -56,7 +56,7 @@ var openBrowserFunc = tryOpenBrowser
var (
patAuthorizationTimeout = PatAuthRetryTimeout
patAuthorizationPollInterval = PatAuthPollInterval
patLoadTokenData = authpkg.LoadTokenData
patResolveAccessToken = ResolveAuxiliaryAccessToken
patWaitForAuthorization = WaitForPatAuthorization
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
patSaveAppConfig = authpkg.SaveAppConfig
@@ -272,7 +272,7 @@ func patAuthorizationURIFromData(data map[string]any) string {
// WaitForPatAuthorization polls until the user completes authorization or timeout.
// It returns true if authorization was completed, false if timed out or cancelled.
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) (bool, error) {
timeout := patAuthorizationTimeout
deadline := time.Now().Add(timeout)
pollTicker := time.NewTicker(patAuthorizationPollInterval)
@@ -290,27 +290,26 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
select {
case <-ctx.Done():
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
return false
return false, ctx.Err()
case <-time.After(time.Until(deadline)):
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
return false
return false, nil
case <-pollTicker.C:
pollCount++
elapsed := time.Since(start).Truncate(time.Second)
remaining := time.Until(deadline).Truncate(time.Second)
// Check if token is now valid
tokenData, err := patLoadTokenData(configDir)
if err == nil && tokenData != nil {
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
fmt.Fprintln(output)
return true
}
// Check the same resolver used by every outbound bearer request.
if _, err := patResolveAccessToken(ctx, configDir, ""); err == nil {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
fmt.Fprintln(output)
return true, nil
} else if !stderrors.Is(err, authpkg.ErrTokenDataNotFound) {
return false, fmt.Errorf("check authorization token: %w", err)
}
// Show polling status
@@ -340,7 +339,10 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
PrintPatAuthError(output, scopeErr)
// Wait for user to complete authorization
authorized := patWaitForAuthorization(ctx, configDir, output)
authorized, waitErr := patWaitForAuthorization(ctx, configDir, output)
if waitErr != nil {
return executor.Result{}, waitErr
}
if !authorized {
return executor.Result{}, apperrors.NewAuth(
"等待用户授权超时",
@@ -794,12 +796,6 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
pollURL := fmt.Sprintf("%s%s?flowId=%s",
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
// Load user access token for the poll request header.
var accessToken string
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
accessToken = tokenData.AccessToken
}
// Use a client that does NOT follow redirects, so we can detect SSO 302.
noRedirectClient := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
@@ -828,6 +824,10 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
slog.Debug("PAT poll: failed to create request", "error", err)
continue
}
accessToken, tokenErr := patResolveAccessToken(ctx, configDir, "")
if tokenErr != nil && !stderrors.Is(tokenErr, authpkg.ErrTokenDataNotFound) {
return "", "", fmt.Errorf("resolve PAT poll access token: %w", tokenErr)
}
if accessToken != "" {
req.Header.Set("x-user-access-token", accessToken)
}
@@ -52,39 +52,41 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
oldTimeout := patAuthorizationTimeout
oldInterval := patAuthorizationPollInterval
oldLoad := patLoadTokenData
oldResolve := patResolveAccessToken
t.Cleanup(func() {
patAuthorizationTimeout = oldTimeout
patAuthorizationPollInterval = oldInterval
patLoadTokenData = oldLoad
patResolveAccessToken = oldResolve
})
patAuthorizationTimeout = 50 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
patResolveAccessToken = func(context.Context, string, string) (string, error) {
return "token", nil
}
out.Reset()
if !WaitForPatAuthorization(context.Background(), "", &out) {
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || !ok {
t.Fatal("valid token did not authorize")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
out.Reset()
if WaitForPatAuthorization(ctx, "", &out) {
t.Fatal("cancelled authorization succeeded")
if ok, err := WaitForPatAuthorization(ctx, "", &out); ok || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = time.Millisecond
patAuthorizationPollInterval = time.Hour
out.Reset()
if WaitForPatAuthorization(context.Background(), "", &out) {
t.Fatal("timed out authorization succeeded")
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
t.Fatalf("timed out authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationPollInterval = time.Millisecond
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
patResolveAccessToken = func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
}
out.Reset()
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling output = %q", out.String())
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
}
}
@@ -109,12 +111,12 @@ func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing.
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false }
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return false, nil }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
t.Fatal("PAT retry timeout succeeded")
}
wantErr := errors.New("runner failed")
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true }
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return true, nil }
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
t.Fatalf("authorized retry = %v", err)
}
@@ -215,15 +217,15 @@ func patRaw(flowID, clientID, secret string) string {
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
oldDo := patPollHTTPDo
oldRequest := patPollNewRequest
oldLoad := patLoadTokenData
oldResolve := patResolveAccessToken
oldBrowser := patBrowserOpenCommand
t.Cleanup(func() {
patPollHTTPDo = oldDo
patPollNewRequest = oldRequest
patLoadTokenData = oldLoad
patResolveAccessToken = oldResolve
patBrowserOpenCommand = oldBrowser
})
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil }
patResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
cancelled, cancelNow := context.WithCancel(context.Background())
cancelNow()
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,675 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginFailRunner struct{}
func (pluginFailRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("runner failed")
}
type pluginWrongFlagValue struct{}
func (pluginWrongFlagValue) String() string { return "" }
func (pluginWrongFlagValue) Set(string) error { return nil }
func (pluginWrongFlagValue) Type() string { return "wrong" }
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
invalidRoot := conferencePluginDescriptor()
invalidRoot.CLI.Command = "Invalid Root"
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{invalidRoot}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("invalid root produced commands %#v", commands)
}
descriptor := conferencePluginDescriptor()
descriptor.CLI.Groups = map[string]mcptypes.CLIGroupDef{
"empty": {Description: "removed when no leaf survives"},
}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"": {CLIName: "blank-tool"},
"hidden": {CLIName: "hidden", Hidden: true},
"invalid": {CLIName: "Invalid Leaf"},
"first": {CLIName: "same"},
"second": {CLIName: "same"},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 {
t.Fatalf("commands = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "same") == nil {
t.Fatal("valid leaf was not retained")
}
if requireOptionalPluginChild(commands[0], "empty") != nil {
t.Fatal("empty group was not pruned")
}
empty := conferencePluginDescriptor()
empty.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"hidden": {CLIName: "hidden", Hidden: true},
}
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{empty}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("empty overlay produced commands %#v", commands)
}
}
func TestPluginLeafExecutionErrorsAndBodyWrapper(t *testing.T) {
base := conferencePluginDescriptor()
base.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"wrapped": {
CLIName: "wrapped",
BodyWrapper: "body",
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Required: true},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, runner, nil)...)
root.SetArgs([]string{"conference", "wrapped", "--value", "ok", "--params", `{"body":{"old":1},"_meta":"kept"}`})
if err := root.Execute(); err != nil {
t.Fatalf("wrapped command: %v", err)
}
want := map[string]any{
"_meta": "kept",
"body": map[string]any{"old": float64(1), "value": "ok"},
}
if !reflect.DeepEqual(runner.invocations[0].Params, want) {
t.Fatalf("wrapped params = %#v, want %#v", runner.invocations[0].Params, want)
}
for _, testCase := range []struct {
name string
runner executor.Runner
args []string
}{
{name: "invalid json", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped", "--json", "["}},
{name: "missing required", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped"}},
{name: "missing runner", runner: nil, args: []string{"conference", "wrapped", "--value", "ok"}},
{name: "runner error", runner: pluginFailRunner{}, args: []string{"conference", "wrapped", "--value", "ok"}},
} {
t.Run(testCase.name, func(t *testing.T) {
commandRoot := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, testCase.runner, nil)...)
commandRoot.SetArgs(testCase.args)
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected command error")
}
})
}
for _, flagName := range []string{"json", "params"} {
t.Run("unreadable "+flagName, func(t *testing.T) {
commands := buildPluginCommands([]mcptypes.ServerDescriptor{base}, executor.EchoRunner{}, nil)
leaf := requirePluginChild(t, commands[0], "wrapped")
leaf.Flags().Lookup(flagName).Value = pluginWrongFlagValue{}
commandRoot := pluginTestRoot(commands...)
commandRoot.SetArgs([]string{"conference", "wrapped", "--value", "ok"})
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected unreadable flag error")
}
})
}
}
func TestPluginBindingCompilerCoversAliasesAndPositionalValidators(t *testing.T) {
reservations := pluginFlagReservations{
names: map[string]bool{"reserved": true},
shorthands: map[string]bool{},
}
bindings, _, _, ok := registerPluginBindings("alias", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: "value", Aliases: []string{"", "Bad", "value", "other"}},
},
}, reservations)
if !ok || !reflect.DeepEqual(bindings[0].names, []string{"value", "other"}) {
t.Fatalf("alias bindings = (%#v, %v)", bindings, ok)
}
if _, _, _, ok := registerPluginBindings("conflict", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "reserved"}},
}, reservations); ok {
t.Fatal("reserved flag was accepted")
}
if _, _, _, ok := registerPluginBindings("negative", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Positional: true, PositionalIndex: -1}},
}, reservations); ok {
t.Fatal("negative positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("duplicate", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 0},
},
}, reservations); ok {
t.Fatal("duplicate positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("gap", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1},
},
}, reservations); ok {
t.Fatal("non-contiguous positional indexes were accepted")
}
for _, testCase := range []struct {
name string
flags map[string]mcptypes.CLIFlagOverride
wantUse string
valid []string
invalid []string
}{
{
name: "exact",
flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1, Required: true},
"first": {Positional: true, PositionalIndex: 0, Required: true},
},
wantUse: "exact [first] [second]", valid: []string{"a", "b"}, invalid: []string{"a"},
},
{
name: "range",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0, Required: true},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "range [first] [second]", valid: []string{"a"}, invalid: []string{},
},
{
name: "maximum",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "maximum [first] [second]", valid: []string{}, invalid: []string{"a", "b", "c"},
},
} {
t.Run(testCase.name, func(t *testing.T) {
_, use, validator, ok := registerPluginBindings(testCase.name, mcptypes.CLIToolOverride{Flags: testCase.flags}, reservations)
if !ok || use != testCase.wantUse {
t.Fatalf("binding contract = (%q, %v)", use, ok)
}
cmd := &cobra.Command{Use: testCase.name}
if err := validator(cmd, testCase.valid); err != nil {
t.Fatalf("valid args: %v", err)
}
if err := validator(cmd, testCase.invalid); err == nil {
t.Fatal("invalid args were accepted")
}
})
}
}
func TestPluginFlagRegistrationAndReadingCoversAllKinds(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
override := mcptypes.CLIToolOverride{Flags: map[string]mcptypes.CLIFlagOverride{
"integer": {Default: "2", Shorthand: "i", Hidden: true},
"float": {Default: "1.5"},
"boolean": {Default: "true"},
"slice": {Default: "one, ,two"},
"json": {Default: `{"old":true}`},
"string": {Default: "text"},
}}
bindings := []pluginFlagBinding{
{property: "integer", names: []string{"integer", "integer-alias"}, kind: pluginFlagInt},
{property: "float", names: []string{"float"}, kind: pluginFlagFloat},
{property: "boolean", names: []string{"boolean"}, kind: pluginFlagBool},
{property: "slice", names: []string{"slice"}, kind: pluginFlagStringSlice},
{property: "json", names: []string{"json-value"}, kind: pluginFlagJSON},
{property: "string", names: []string{"string"}, kind: pluginFlagString},
}
registerPluginFlags(cmd, bindings, override, pluginFlagReservations{shorthands: map[string]bool{}})
for name, raw := range map[string]string{
"integer": "3", "float": "2.5", "boolean": "false",
"slice": "three,four", "json-value": `{"ok":true}`, "string": "changed",
} {
if err := cmd.Flags().Set(name, raw); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
wants := map[string]any{
"integer": 3,
"float": 2.5,
"boolean": false,
"slice": []string{"three", "four"},
"json": map[string]any{"ok": true},
"string": "changed",
}
for _, binding := range bindings {
value, err := readPluginFlag(cmd.Flags(), binding.names[0], binding.kind)
if err != nil || !reflect.DeepEqual(value, wants[binding.property]) {
t.Fatalf("read %s = (%#v, %v), want %#v", binding.property, value, err, wants[binding.property])
}
}
if !cmd.Flags().Lookup("integer").Hidden || !cmd.Flags().Lookup("integer-alias").Hidden {
t.Fatal("hidden primary or alias flag was exposed")
}
if err := cmd.Flags().Set("json-value", "{"); err != nil {
t.Fatal(err)
}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("invalid JSON flag was accepted")
}
cmd.Flags().Lookup("json-value").Value = pluginWrongFlagValue{}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("wrong JSON flag type was accepted")
}
}
func TestCollectPluginBindingsCoversEveryValueSourceAndFailure(t *testing.T) {
t.Run("sources", func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
registerPluginFlag(cmd.Flags(), "flag", "", "", pluginFlagString, "")
if err := cmd.Flags().Set("flag", "from-flag"); err != nil {
t.Fatal(err)
}
t.Setenv("PLUGIN_COVERAGE_ENV", "7")
params := map[string]any{"existing": "from-json"}
bindings := []pluginFlagBinding{
{property: "flag", names: []string{"flag"}, kind: pluginFlagString},
{property: "existing", kind: pluginFlagString},
{property: "positional", kind: pluginFlagBool, positional: true, positionalIndex: 0},
{property: "default", kind: pluginFlagFloat, defaultProvided: true, defaultValue: "1.5"},
{property: "env", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_ENV"},
{property: "optional", kind: pluginFlagString},
}
if err := collectPluginBindings(cmd, []string{"true"}, bindings, params); err != nil {
t.Fatal(err)
}
want := map[string]any{
"flag": "from-flag", "existing": "from-json", "positional": true,
"default": 1.5, "env": 7,
}
if !reflect.DeepEqual(params, want) {
t.Fatalf("params = %#v, want %#v", params, want)
}
})
for _, testCase := range []struct {
name string
prepare func(t *testing.T, cmd *cobra.Command)
args []string
binding pluginFlagBinding
params map[string]any
}{
{
name: "wrong flag type",
prepare: func(t *testing.T, cmd *cobra.Command) {
cmd.Flags().String("value", "", "")
if err := cmd.Flags().Set("value", "x"); err != nil {
t.Fatal(err)
}
},
binding: pluginFlagBinding{property: "value", names: []string{"value"}, kind: pluginFlagInt},
},
{name: "invalid positional", args: []string{"maybe"}, binding: pluginFlagBinding{property: "value", kind: pluginFlagBool, positional: true, positionalIndex: 0}},
{name: "invalid default", binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, defaultProvided: true, defaultValue: "bad"}},
{
name: "invalid env",
prepare: func(t *testing.T, _ *cobra.Command) { t.Setenv("PLUGIN_COVERAGE_BAD_ENV", "bad") },
binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_BAD_ENV"},
},
{name: "missing named required", binding: pluginFlagBinding{property: "value", names: []string{"value"}, required: true}},
{name: "missing positional required", binding: pluginFlagBinding{property: "value", required: true, positional: true, positionalIndex: 0}},
{name: "required omitted", binding: pluginFlagBinding{property: "value", required: true, defaultProvided: true, defaultValue: "", omitWhen: "empty"}},
} {
t.Run(testCase.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
if testCase.prepare != nil {
testCase.prepare(t, cmd)
}
if err := collectPluginBindings(cmd, testCase.args, []pluginFlagBinding{testCase.binding}, testCase.params); err == nil {
t.Fatal("expected binding error")
}
})
}
params := map[string]any{"value": ""}
if err := collectPluginBindings(&cobra.Command{Use: "leaf"}, nil, []pluginFlagBinding{{
property: "value", kind: pluginFlagString, omitWhen: "empty",
}}, params); err != nil {
t.Fatal(err)
}
if _, exists := params["value"]; exists {
t.Fatal("optional empty value was not omitted")
}
}
func TestPluginValueAndNamingHelpers(t *testing.T) {
parseCases := []struct {
kind pluginFlagKind
raw string
want any
}{
{pluginFlagInt, " 2 ", 2},
{pluginFlagFloat, " 2.5 ", 2.5},
{pluginFlagBool, "true", true},
{pluginFlagStringSlice, "one, ,two", []string{"one", "two"}},
{pluginFlagJSON, `{"ok":true}`, map[string]any{"ok": true}},
{pluginFlagString, " raw ", " raw "},
}
for _, testCase := range parseCases {
got, err := parsePluginValue(testCase.raw, testCase.kind)
if err != nil || !reflect.DeepEqual(got, testCase.want) {
t.Fatalf("parse %q = (%#v, %v), want %#v", testCase.raw, got, err, testCase.want)
}
}
for _, testCase := range []struct {
kind pluginFlagKind
raw string
}{
{pluginFlagInt, "bad"}, {pluginFlagFloat, "bad"}, {pluginFlagBool, "bad"}, {pluginFlagJSON, "{"},
} {
if _, err := parsePluginValue(testCase.raw, testCase.kind); err == nil {
t.Fatalf("invalid %q was accepted", testCase.raw)
}
}
omitCases := []struct {
value any
mode string
want bool
}{
{nil, "", true}, {" ", "", true}, {[]string{}, "", true},
{"", "never", false}, {false, "zero", true}, {0, "zero", true},
{float64(0), "zero", true}, {true, "zero", false}, {1, "zero", false},
{float64(1), "zero", false}, {[]any{}, "zero", true}, {map[string]any{}, "zero", true},
{[]any{"value"}, "zero", false}, {map[string]any{"value": true}, "zero", false},
{struct{}{}, "zero", false}, {false, "", false},
}
for _, testCase := range omitCases {
if got := shouldOmitPluginValue(testCase.value, testCase.mode); got != testCase.want {
t.Fatalf("omit (%#v, %q) = %v, want %v", testCase.value, testCase.mode, got, testCase.want)
}
}
wrapPluginParams(nil, "body")
untouched := map[string]any{"value": 1}
wrapPluginParams(untouched, " ")
wrapped := map[string]any{"body": map[string]any{"old": 1}, "value": 2, "_meta": 3}
wrapPluginParams(wrapped, "body")
wantWrapped := map[string]any{"body": map[string]any{"old": 1, "value": 2}, "_meta": 3}
if !reflect.DeepEqual(wrapped, wantWrapped) {
t.Fatalf("wrapped = %#v, want %#v", wrapped, wantWrapped)
}
kinds := map[string]pluginFlagKind{
"int": pluginFlagInt, "integer": pluginFlagInt,
"float": pluginFlagFloat, "float64": pluginFlagFloat, "number": pluginFlagFloat,
"bool": pluginFlagBool, "boolean": pluginFlagBool,
"stringSlice": pluginFlagStringSlice, "string_slice": pluginFlagStringSlice,
"array": pluginFlagStringSlice, "[]string": pluginFlagStringSlice,
"json": pluginFlagJSON, "object": pluginFlagJSON, "unknown": pluginFlagString,
}
for raw, want := range kinds {
if got := pluginFlagKindFromString(raw); got != want {
t.Fatalf("kind %q = %v, want %v", raw, got, want)
}
}
used := map[string]bool{}
reserved := map[string]bool{"r": true}
if got := safePluginShorthand(" x ", used, reserved); got != "x" || !used["x"] {
t.Fatalf("safe shorthand = %q / %#v", got, used)
}
for _, raw := range []string{"", "xy", "x", "r"} {
if got := safePluginShorthand(raw, used, reserved); got != "" {
t.Fatalf("unsafe shorthand %q = %q", raw, got)
}
}
baseReservations := pluginReservedFlags(nil)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().StringP("custom", "c", "", "")
rootReservations := pluginReservedFlags(root)
if !baseReservations.names["yes"] || !rootReservations.names["custom"] || !rootReservations.shorthands["c"] {
t.Fatalf("reservations = %#v / %#v", baseReservations, rootReservations)
}
if got := safePluginAliases([]string{"", "help", "auth", "cmd", "cmd", "ok", "Bad"}, "cmd"); !reflect.DeepEqual(got, []string{"ok"}) {
t.Fatalf("aliases = %#v", got)
}
if got := derivePluginCommandName("conference_getCurrent2Status", []string{"other", "conference"}); got != "get-current2-status" {
t.Fatalf("derived name = %q", got)
}
if got := pluginKebabName(" HTTP2.Foo_bar baz@ "); got != "http2-foo-bar-baz@" {
t.Fatalf("kebab name = %q", got)
}
for _, name := range []string{"", "1bad", "bad-", "bad--name", "bad_name", "bad@name"} {
if validPluginKebabName(name) {
t.Fatalf("invalid kebab name %q was accepted", name)
}
}
if !validPluginKebabName("good-name2") || validPluginCommandName("help") || validPluginFlagName("json") || validPluginFlagName("params") {
t.Fatal("name validation contract failed")
}
if got := firstNonEmptyPluginString(" ", " value "); got != "value" || firstNonEmptyPluginString("", " ") != "" {
t.Fatal("first non-empty string contract failed")
}
}
func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
for _, name := range []string{"a", "b", "c"} {
cmd.Flags().String(name, "", "")
}
applyPluginFlagConstraints(cmd, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}, {"a", "missing"}},
RequireOneOf: [][]string{{"a", "b"}, {"missing"}},
RequireTogether: [][]string{{"b", "c"}, {"c", "missing"}},
})
bindings := []pluginFlagBinding{{names: []string{"a"}}, {names: []string{"b"}}, {names: []string{"c"}}}
if !validPluginFlagConstraints(bindings, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}},
RequireOneOf: [][]string{{"a"}},
RequireTogether: [][]string{{"b", "c"}},
}) {
t.Fatal("valid plugin constraints were rejected")
}
for _, invalid := range []mcptypes.CLIToolOverride{
{MutuallyExclusive: [][]string{{"a"}}},
{RequireOneOf: [][]string{{"missing"}}},
{RequireTogether: [][]string{{"a", "a"}}},
} {
if validPluginFlagConstraints(bindings, invalid) {
t.Fatalf("invalid plugin constraints were accepted: %#v", invalid)
}
}
groups := map[string]*cobra.Command{}
root := &cobra.Command{Use: "root"}
group := ensurePluginGroup(root, "parent.child", "child description", groups)
if group.Name() != "child" || group.Short != "child description" || !cmdutil.IsPluginSourced(group) {
t.Fatalf("group = %#v", group)
}
if again := ensurePluginGroup(root, "parent.child", "ignored", groups); again != group {
t.Fatal("existing group was not reused")
}
for _, invalid := range []string{"safe.bad_name", "_bad", ".parent", "parent."} {
if got := ensurePluginGroup(root, invalid, "invalid", groups); got != nil {
t.Fatalf("invalid group path %q produced %#v", invalid, got)
}
}
mergePluginRoot(nil, root)
mergePluginRoot(root, nil)
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
source.AddCommand(&cobra.Command{Use: "leaf"})
mergePluginRoot(destination, source)
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
t.Fatalf("merged root = %#v", destination)
}
pruneEmptyPluginGroups(nil)
pruneRoot := &cobra.Command{Use: "root"}
empty := cobracmd.NewGroupCommand("empty", "empty")
nonEmpty := cobracmd.NewGroupCommand("non-empty", "non-empty")
nonEmpty.AddCommand(&cobra.Command{Use: "leaf"})
pruneRoot.AddCommand(empty, nonEmpty)
pruneEmptyPluginGroups(pruneRoot)
if requireOptionalPluginChild(pruneRoot, "empty") != nil || requireOptionalPluginChild(pruneRoot, "non-empty") == nil {
t.Fatal("empty plugin groups were not pruned correctly")
}
if pluginRootBoolFlag(nil, "yes") {
t.Fatal("nil command reported a root flag")
}
noFlag := &cobra.Command{Use: "root"}
if pluginRootBoolFlag(noFlag, "yes") {
t.Fatal("missing flag reported true")
}
wrongType := &cobra.Command{Use: "root"}
wrongType.PersistentFlags().String("yes", "true", "")
if pluginRootBoolFlag(wrongType, "yes") {
t.Fatal("wrong flag type reported true")
}
boolRoot := &cobra.Command{Use: "root"}
boolRoot.PersistentFlags().Bool("yes", false, "")
if err := boolRoot.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
if !pluginRootBoolFlag(boolRoot, "yes") {
t.Fatal("true root flag was not observed")
}
if err := pluginConfirmationRequired("dws plugin"); err == nil || !strings.Contains(err.Error(), "sensitive") {
t.Fatalf("confirmation error = %v", err)
}
}
func TestUnsupportedPluginSemanticsReportEveryField(t *testing.T) {
overlays := []struct {
value mcptypes.CLIOverlay
want string
}{
{mcptypes.CLIOverlay{Parent: "root"}, "parent"},
{mcptypes.CLIOverlay{Group: "group"}, "group"},
{mcptypes.CLIOverlay{ServerDeps: []string{"other"}}, "serverDeps"},
{mcptypes.CLIOverlay{Hints: map[string]json.RawMessage{"x": json.RawMessage(`{}`)}}, "hintCommands"},
{mcptypes.CLIOverlay{RedirectTo: "other"}, "redirectTo"},
{mcptypes.CLIOverlay{}, ""},
}
for _, testCase := range overlays {
if got := unsupportedPluginOverlay(testCase.value); got != testCase.want {
t.Fatalf("unsupported overlay = %q, want %q", got, testCase.want)
}
}
tools := []struct {
value mcptypes.CLIToolOverride
want string
}{
{mcptypes.CLIToolOverride{CLIAliases: []string{"x"}}, "cliAliases"},
{mcptypes.CLIToolOverride{OutputFormat: map[string]any{"x": true}}, "outputFormat"},
{mcptypes.CLIToolOverride{ServerOverride: "other"}, "serverOverride"},
{mcptypes.CLIToolOverride{RedirectTo: "x"}, "redirectTo"},
{mcptypes.CLIToolOverride{Pipeline: []json.RawMessage{json.RawMessage(`{}`)}}, "pipeline"},
{mcptypes.CLIToolOverride{}, ""},
}
for _, testCase := range tools {
if got := unsupportedPluginToolOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported tool = %q, want %q", got, testCase.want)
}
}
flags := []struct {
value mcptypes.CLIFlagOverride
want string
}{
{mcptypes.CLIFlagOverride{MapsTo: "x"}, "mapsTo"},
{mcptypes.CLIFlagOverride{Transform: "x"}, "transform"},
{mcptypes.CLIFlagOverride{TransformArgs: map[string]any{"x": true}}, "transformArgs"},
{mcptypes.CLIFlagOverride{RuntimeDefault: "x"}, "runtimeDefault"},
{mcptypes.CLIFlagOverride{PipelineLocal: true}, "pipelineLocal"},
{mcptypes.CLIFlagOverride{Type: "mystery"}, "type"},
{mcptypes.CLIFlagOverride{OmitWhen: "sometimes"}, "omitWhen"},
{mcptypes.CLIFlagOverride{}, ""},
}
for _, testCase := range flags {
if got := unsupportedPluginFlagOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported flag = %q, want %q", got, testCase.want)
}
}
for _, value := range []string{"", "string", "integer", "float64", "boolean", "stringSlice", "array", "json", "object"} {
if !supportedPluginFlagType(value) {
t.Fatalf("supported plugin flag type %q was rejected", value)
}
}
for _, value := range []string{"", "empty", "zero", "never"} {
if !supportedPluginOmitMode(value) {
t.Fatalf("supported plugin omit mode %q was rejected", value)
}
}
}
func TestUnsupportedPluginDescriptorRejectsEveryInvalidLayer(t *testing.T) {
testCases := []struct {
name string
mutate func(*mcptypes.ServerDescriptor)
want string
}{
{name: "overlay", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Parent = "root" }, want: "parent"},
{name: "no tools", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.ToolOverrides = nil }, want: ""},
{name: "root", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Command = "Bad" }, want: "command"},
{name: "declared group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.Groups = map[string]mcptypes.CLIGroupDef{"bad_name": {}}
}, want: "groups"},
{name: "blank tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"": {}}
}, want: "tool"},
{name: "tool semantics", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {ServerOverride: "drive"}}
}, want: "serverOverride"},
{name: "hidden tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {Hidden: true, ServerOverride: "drive"}}
}, want: ""},
{name: "derived leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"conference_derived_tool": {}}
}, want: ""},
{name: "leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "Bad"}}
}, want: "cliName"},
{name: "leaf group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "leaf", Group: "bad_name"}}
}, want: "group"},
{name: "flags", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "yes"}},
}}
}, want: "flags"},
{name: "constraints", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
}}
}, want: "constraints"},
{name: "valid", want: ""},
}
root := pluginTestRoot()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
if testCase.mutate != nil {
testCase.mutate(&descriptor)
}
if got := unsupportedPluginDescriptor(root, descriptor); got != testCase.want {
t.Fatalf("unsupported descriptor = %q, want %q", got, testCase.want)
}
})
}
}
+809
View File
@@ -0,0 +1,809 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginCaptureRunner struct {
invocations []executor.Invocation
}
func (r *pluginCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.invocations = append(r.invocations, invocation)
return executor.Result{Invocation: invocation}, nil
}
func conferencePluginDescriptor() mcptypes.ServerDescriptor {
return mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
Description: "conference plugin",
Endpoint: "stdio://conference/conference-local",
Source: "plugin",
HasCLIMeta: true,
CLI: mcptypes.CLIOverlay{
ID: "conference-local",
Command: "conference",
Description: "视频会议:发起/邀请入会/会中控制",
Prefixes: []string{"conference"},
Groups: map[string]mcptypes.CLIGroupDef{
"camera": {Description: "摄像头控制"},
"mic": {Description: "麦克风控制"},
"share": {Description: "屏幕共享"},
},
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"create_conference": {
CLIName: "start",
Description: "发起即时会议",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Description: "会议标题"},
},
},
"get_conference_status": {
CLIName: "status",
Description: "查询当前会议状态",
},
"ai_end_meeting_for_all": {
CLIName: "end",
Description: "结束会议(所有人)",
IsSensitive: true,
},
"ai_open_camera": {
CLIName: "open",
Group: "camera",
Description: "打开摄像头",
},
"ai_mute_mic": {
CLIName: "mute",
Group: "mic",
Description: "静音自己",
},
"ai_share_desktop": {
CLIName: "start",
Group: "share",
Description: "开始共享桌面",
Flags: map[string]mcptypes.CLIFlagOverride{
"capture_speaker": {Description: "是否共享电脑音频"},
},
},
},
},
}
}
func pluginTestRoot(commands ...*cobra.Command) *cobra.Command {
root := &cobra.Command{
Use: "dws",
SilenceErrors: true,
SilenceUsage: true,
}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.AddCommand(commands...)
return root
}
func requirePluginChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
current := parent
for _, name := range names {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == name {
next = child
break
}
}
if next == nil {
t.Fatalf("missing plugin command %q below %q", name, current.CommandPath())
}
current = next
}
return current
}
func TestPluginOverlayBuildsConferenceTreeAndDispatchesOriginalProperties(t *testing.T) {
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)
if len(commands) != 1 {
t.Fatalf("plugin roots = %d, want 1", len(commands))
}
conference := commands[0]
if conference.Name() != "conference" || conference.Short != "视频会议:发起/邀请入会/会中控制" {
t.Fatalf("conference root = %q / %q", conference.Name(), conference.Short)
}
if !cmdutil.IsPluginSourced(conference) {
t.Fatal("conference root is missing plugin provenance")
}
if got := requirePluginChild(t, conference, "camera").Short; got != "摄像头控制" {
t.Fatalf("camera group short = %q", got)
}
if got := requirePluginChild(t, conference, "camera", "open").Short; got != "打开摄像头" {
t.Fatalf("camera open short = %q", got)
}
requirePluginChild(t, conference, "mic", "mute")
requirePluginChild(t, conference, "status")
share := requirePluginChild(t, conference, "share", "start")
flag := share.Flags().Lookup("capture-speaker")
if flag == nil || flag.Usage != "是否共享电脑音频" {
t.Fatalf("capture-speaker flag = %#v", flag)
}
root := pluginTestRoot(commands...)
root.SetArgs([]string{
"conference", "start",
"--json", `{"from_json":"kept","title":"json"}`,
"--params", `{"from_params":2,"title":"params"}`,
"--title", "验证会议",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("conference start: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d, want 1", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.Kind != "compat_invocation" ||
invocation.CanonicalProduct != "conference-local" ||
invocation.Tool != "create_conference" ||
!invocation.DryRun {
t.Fatalf("conference invocation = %#v", invocation)
}
wantParams := map[string]any{
"from_json": "kept",
"from_params": float64(2),
"title": "验证会议",
}
if !reflect.DeepEqual(invocation.Params, wantParams) {
t.Fatalf("conference params = %#v, want %#v", invocation.Params, wantParams)
}
precedenceRunner := &pluginCaptureRunner{}
precedenceRoot := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
precedenceRunner,
nil,
)...)
precedenceRoot.SetArgs([]string{
"conference", "start",
"--json", `{"title":"json"}`,
"--params", `{"title":"params"}`,
"--dry-run",
})
if err := precedenceRoot.Execute(); err != nil {
t.Fatalf("conference payload precedence: %v", err)
}
if got := precedenceRunner.invocations[0].Params["title"]; got != "params" {
t.Fatalf("conference payload title = %#v, want --params value", got)
}
}
func TestPluginOverlayTypedFlags(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"typed_tool": {
CLIName: "typed",
Flags: map[string]mcptypes.CLIFlagOverride{
"conversationId": {Required: true, Description: "conversation"},
"enabled": {Type: "bool"},
"limit": {Type: "int"},
"tags": {Type: "stringSlice"},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, nil)...)
root.SetArgs([]string{
"conference", "typed",
"--conversation-id", "cid",
"--enabled=false",
"--limit", "3",
"--tags", "one,two",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("typed plugin command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.CanonicalProduct != "conference-local" {
t.Fatalf("canonical product = %q", invocation.CanonicalProduct)
}
want := map[string]any{
"conversationId": "cid",
"enabled": false,
"limit": 3,
"tags": []string{"one", "two"},
}
if !reflect.DeepEqual(invocation.Params, want) {
t.Fatalf("typed params = %#v, want %#v", invocation.Params, want)
}
}
func TestPluginSensitiveCommandRequiresConfirmation(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
wantCalls int
wantDry bool
wantError bool
}{
{name: "blocked", args: []string{"conference", "end"}, wantError: true},
{name: "preview", args: []string{"conference", "end", "--dry-run"}, wantCalls: 1, wantDry: true},
{name: "confirmed", args: []string{"conference", "end", "--yes"}, wantCalls: 1},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)...)
root.SetArgs(testCase.args)
err := root.Execute()
if testCase.wantError {
var appErr *apperrors.Error
if !errors.As(err, &appErr) ||
appErr.Category != apperrors.CategoryValidation ||
appErr.Reason != "confirmation_required" {
t.Fatalf("sensitive error = %#v", err)
}
} else if err != nil {
t.Fatalf("sensitive command: %v", err)
}
if len(runner.invocations) != testCase.wantCalls {
t.Fatalf("runner calls = %d, want %d", len(runner.invocations), testCase.wantCalls)
}
if testCase.wantCalls == 1 && runner.invocations[0].DryRun != testCase.wantDry {
t.Fatalf("dry-run = %v, want %v", runner.invocations[0].DryRun, testCase.wantDry)
}
})
}
}
func TestPluginOverlayMergesServersWithoutProbingHTTP(t *testing.T) {
isolatePluginRuntime(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
first := conferencePluginDescriptor()
first.Endpoint = server.URL
first.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"one": {CLIName: "one"},
}
second := first
second.Key = "conference-extra"
second.DisplayName = "conference/conference-extra"
second.Endpoint = server.URL + "/extra"
second.CLI.ID = "conference-extra"
second.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"two": {CLIName: "two"},
}
registerPluginHTTPServer(first)
registerPluginHTTPServer(second)
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{second, first}, runner, nil)
if len(commands) != 1 {
t.Fatalf("merged roots = %d, want 1", len(commands))
}
requirePluginChild(t, commands[0], "one")
requirePluginChild(t, commands[0], "two")
root := pluginTestRoot(commands...)
root.SetArgs([]string{"conference", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("conference help: %v", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls while building help = %d, want 0", got)
}
for _, command := range []string{"one", "two"} {
root.SetArgs([]string{"conference", command, "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("conference %s: %v", command, err)
}
}
if len(runner.invocations) != 2 ||
runner.invocations[0].CanonicalProduct != "conference-local" ||
runner.invocations[1].CanonicalProduct != "conference-extra" {
t.Fatalf("merged routes = %#v", runner.invocations)
}
}
func TestPluginCanReplaceHiddenFallbackButNotVisibleDistributionCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
fallback := &cobra.Command{Use: "conference", Hidden: true}
fallback.AddCommand(&cobra.Command{Use: "meeting"})
distribution := &cobra.Command{Use: "drive"}
root.AddCommand(fallback, distribution)
conference := buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)[0]
drive := &cobra.Command{Use: "drive"}
cmdutil.MarkPluginSource(drive)
addPluginCommandsSafe(root, []*cobra.Command{conference, drive})
gotConference := requirePluginChild(t, root, "conference")
if gotConference == fallback || gotConference.Hidden {
t.Fatalf("conference fallback was not replaced: %#v", gotConference)
}
requirePluginChild(t, gotConference, "status")
if gotDrive := requirePluginChild(t, root, "drive"); gotDrive != distribution {
t.Fatal("visible distribution command was replaced by a plugin")
}
}
func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
pluginDir := filepath.Join(configDir, "plugins", "user", "drive-hijack")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name":"drive-hijack",
"version":"1.0.0",
"mcpServers":{
"drive":{
"type":"streamable-http",
"endpoint":"https://plugin.invalid/mcp",
"cli":{
"id":"drive-service",
"command":"drive-hijack",
"toolOverrides":{"plugin_tool":{"cliName":"plugin-tool"}}
}
}
}
}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: "drive",
Endpoint: "https://distribution.invalid/mcp",
CLI: mcptypes.CLIOverlay{ID: "drive-service", Command: "drive"},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "drive"})
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("conflicting plugin commands = %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("drive-service", "plugin_tool"); !ok ||
endpoint != "https://distribution.invalid/mcp" {
t.Fatalf("drive endpoint after rejected plugin = (%q, %v)", endpoint, ok)
}
}
func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
isolatePluginRuntime(t)
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
var calls atomic.Int32
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
calls.Add(1)
AppendDynamicServer(conferencePluginDescriptor())
return buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)
}
base := NewSchemaSourceRootCommand()
if calls.Load() != 0 {
t.Fatalf("Schema source root loaded plugins %d times", calls.Load())
}
baseConference := requirePluginChild(t, base, "conference")
if !baseConference.Hidden || requireOptionalPluginChild(baseConference, "status") != nil {
t.Fatal("Schema source root contains installed conference plugin commands")
}
runtime := NewRootCommand()
if calls.Load() != 1 {
t.Fatalf("runtime root plugin loads = %d, want 1", calls.Load())
}
runtimeConference := requirePluginChild(t, runtime, "conference")
if runtimeConference.Hidden {
t.Fatal("runtime conference plugin is hidden")
}
requirePluginChild(t, runtimeConference, "status")
}
func requireOptionalPluginChild(parent *cobra.Command, name string) *cobra.Command {
for _, child := range parent.Commands() {
if child.Name() == name {
return child
}
}
return nil
}
func TestPluginDerivedNamesAndReservedAliases(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.Aliases = []string{"auth", "conf", "conf"}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"conference_getCurrentStatus": {},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 || !reflect.DeepEqual(commands[0].Aliases, []string{"conf"}) {
t.Fatalf("plugin aliases = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "get-current-status") == nil {
var names []string
for _, command := range commands[0].Commands() {
names = append(names, command.Name())
}
t.Fatalf("derived command missing, got %s", strings.Join(names, ", "))
}
}
func TestPluginFlagsCannotShadowHostControls(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
reservations := pluginReservedFlags(host)
for name := range reservations.names {
t.Run(name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
IsSensitive: true,
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: name},
},
},
}
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
host,
); len(commands) != 0 {
t.Fatalf("reserved host flag %q produced commands %#v", name, commands)
}
})
}
}
func TestPluginShorthandsCannotShadowHostOrHelp(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"safe": {
CLIName: "safe",
Flags: map[string]mcptypes.CLIFlagOverride{
"alpha": {Shorthand: "f"},
"bravo": {Shorthand: "h"},
"charlie": {Shorthand: "o"},
"delta": {Shorthand: "v"},
"echo": {Shorthand: "x"},
"foxtrot": {Shorthand: "y"},
},
},
}
runner := &pluginCaptureRunner{}
commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
host,
)
if len(commands) != 1 {
t.Fatalf("plugin commands = %#v", commands)
}
host.AddCommand(commands...)
leaf := requirePluginChild(t, commands[0], "safe")
for _, name := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} {
if shorthand := leaf.Flags().Lookup(name).Shorthand; shorthand != "" {
t.Fatalf("--%s shorthand = %q, want empty", name, shorthand)
}
}
host.SetArgs([]string{"conference", "safe", "-h"})
if err := host.Execute(); err != nil {
t.Fatalf("plugin help: %v", err)
}
if len(runner.invocations) != 0 {
t.Fatalf("help executed plugin: %#v", runner.invocations)
}
}
func TestPluginPayloadPrecedenceRequiredAndTypedPositionals(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"payload": {
CLIName: "payload",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Required: true},
"mode": {Default: "fallback"},
"enabled": {Positional: true, PositionalIndex: 0, Alias: "enabled-value", Required: true, Type: "bool"},
},
},
}
for _, testCase := range []struct {
name string
args []string
wantEnabled bool
}{
{
name: "flag satisfies dual positional",
args: []string{
"conference", "payload",
"--params", `{"title":"from-json","mode":"from-json"}`,
"--enabled-value=true",
"--dry-run",
},
wantEnabled: true,
},
{
name: "json beats positional",
args: []string{
"conference", "payload", "true",
"--params", `{"title":"from-json","mode":"from-json","enabled":false}`,
"--dry-run",
},
wantEnabled: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
nil,
)...)
root.SetArgs(testCase.args)
if err := root.Execute(); err != nil {
t.Fatalf("payload command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
params := runner.invocations[0].Params
if params["title"] != "from-json" ||
params["mode"] != "from-json" ||
params["enabled"] != testCase.wantEnabled {
t.Fatalf("payload params = %#v", params)
}
})
}
}
func TestPluginDescriptorWinnerKeepsRouteAuthAndClientAtomic(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("alpha-plugin", `{
"name":"alpha-plugin",
"version":"1.0.0",
"mcpServers":{
"alpha":{
"type":"streamable-http",
"endpoint":"https://alpha.invalid/mcp",
"headers":{"Authorization":"Bearer alpha-secret"},
"cli":{
"id":"shared-plugin-id",
"command":"alpha-command",
"toolOverrides":{"alpha_tool":{"cliName":"alpha"}}
}
},
"alpha-extra":{
"type":"streamable-http",
"endpoint":"https://alpha-extra.invalid/mcp",
"cli":{
"id":"alpha-extra-id",
"command":"alpha-command",
"toolOverrides":{"extra_tool":{"cliName":"extra"}}
}
}
}
}`)
writeManifest("beta-plugin", `{
"name":"beta-plugin",
"version":"1.0.0",
"mcpServers":{
"beta":{
"type":"stdio",
"command":"bin/beta",
"cli":{
"id":"shared-plugin-id",
"command":"beta-command",
"toolOverrides":{"beta_tool":{"cliName":"beta"}}
}
}
}
}`)
root := pluginTestRoot()
commands := loadPlugins(root, nil, executor.EchoRunner{})
if len(commands) != 1 || commands[0].Name() != "alpha-command" {
t.Fatalf("plugin winner commands = %#v", commands)
}
requirePluginChild(t, commands[0], "alpha")
requirePluginChild(t, commands[0], "extra")
endpoint, ok := directRuntimeEndpoint("shared-plugin-id", "alpha_tool")
if !ok || endpoint != "https://alpha.invalid/mcp" {
t.Fatalf("winner endpoint = (%q, %v)", endpoint, ok)
}
extraEndpoint, ok := directRuntimeEndpoint("alpha-extra-id", "extra_tool")
if !ok || extraEndpoint != "https://alpha-extra.invalid/mcp" {
t.Fatalf("merged server endpoint = (%q, %v)", extraEndpoint, ok)
}
auth, ok := LookupPluginAuth("shared-plugin-id")
if !ok || auth.Token != "alpha-secret" {
t.Fatalf("winner auth = (%#v, %v)", auth, ok)
}
if _, ok := LookupStdioClient("beta-plugin/beta"); ok {
t.Fatal("losing stdio client was registered")
}
}
func TestUnsupportedPluginOverlaySemanticsFailClosed(t *testing.T) {
for _, mutate := range []func(*mcptypes.ServerDescriptor){
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.RedirectTo = "drive"
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"source": {MapsTo: "target"},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Pipeline: []json.RawMessage{json.RawMessage(`{"tool":"one"}`)},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", ServerOverride: "drive"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"Body.query": {},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", Group: "safe.bad_name"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
},
}
},
} {
descriptor := conferencePluginDescriptor()
mutate(&descriptor)
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
nil,
); len(commands) != 0 {
t.Fatalf("unsupported overlay produced commands %#v", commands)
}
}
}
func TestUnsupportedPluginDescriptorsDoNotRegisterRuntimeState(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("unsafe-http", `{
"name":"unsafe-http",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"streamable-http",
"endpoint":"https://unsafe.invalid/mcp",
"headers":{"Authorization":"Bearer unsafe-secret"},
"cli":{"id":"unsafe-http-id","command":"unsafe-http","toolOverrides":{
"unsafe_tool":{"cliName":"run","serverOverride":"drive"}
}}
}}
}`)
writeManifest("unsafe-stdio", `{
"name":"unsafe-stdio",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"stdio",
"command":"bin/unsafe",
"cli":{"id":"unsafe-stdio-id","command":"unsafe-stdio","toolOverrides":{
"unsafe_tool":{"cliName":"run","flags":{"value":{"mapsTo":"target"}}}
}}
}}
}`)
root := pluginTestRoot()
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("unsupported plugin descriptors produced commands %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("unsafe-http-id", "unsafe_tool"); ok {
t.Fatalf("unsupported HTTP descriptor registered endpoint %q", endpoint)
}
if _, ok := LookupPluginAuth("unsafe-http-id"); ok {
t.Fatal("unsupported HTTP descriptor registered plugin auth")
}
if _, ok := LookupStdioClient("unsafe-stdio/unsafe"); ok {
t.Fatal("unsupported stdio descriptor registered a client")
}
}
+239
View File
@@ -0,0 +1,239 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func pluginToolInputSchema(
tools transport.ToolsListResult,
toolName string,
) (map[string]any, bool) {
for _, tool := range tools.Tools {
if strings.TrimSpace(tool.Name) == strings.TrimSpace(toolName) {
return tool.InputSchema, true
}
}
return nil, false
}
func normalizePluginInputParams(
params map[string]any,
schema map[string]any,
) (map[string]any, error) {
schema = canonicalPluginInputSchema(schema)
normalized := make(map[string]any, len(params))
for key, value := range params {
normalized[key] = value
}
if _, err := coercePluginSchemaValue(normalized, schema); err != nil {
return nil, cliInputValidationError(err)
}
if err := cli.ValidateInputSchema(normalized, schema); err != nil {
return nil, err
}
return normalized, nil
}
func canonicalPluginInputSchema(schema map[string]any) map[string]any {
if len(schema) == 0 {
return schema
}
cloned := make(map[string]any, len(schema))
for key, value := range schema {
cloned[key] = clonePluginSchemaValue(key, value)
}
return cloned
}
func clonePluginSchemaValue(key string, value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for childKey, childValue := range typed {
cloned[childKey] = clonePluginSchemaValue(childKey, childValue)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = clonePluginSchemaValue(key, item)
}
return cloned
case []string:
cloned := make([]string, len(typed))
for index, item := range typed {
if key == "type" {
item = canonicalPluginSchemaType(item)
}
cloned[index] = item
}
return cloned
case string:
if key == "type" {
return canonicalPluginSchemaType(typed)
}
return typed
default:
return value
}
}
func canonicalPluginSchemaType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "bool":
return "boolean"
case "int":
return "integer"
case "float":
return "number"
default:
return value
}
}
func cliInputValidationError(err error) error {
if err == nil {
return nil
}
return apperrors.NewValidation(
fmt.Sprintf("input schema normalization failed: %v", err),
apperrors.WithReason("plugin_input_schema_invalid"),
)
}
func coercePluginSchemaValue(value any, schema map[string]any) (any, error) {
target := singlePluginSchemaType(schema)
if raw, ok := value.(string); ok {
trimmed := strings.TrimSpace(raw)
switch target {
case "bool", "boolean":
parsed, err := strconv.ParseBool(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to boolean: %w", raw, err)
}
value = parsed
case "int", "integer":
parsed, err := strconv.Atoi(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to integer: %w", raw, err)
}
value = parsed
case "float", "number":
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to number: %w", raw, err)
}
value = parsed
case "object":
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: %w", err)
}
if parsed == nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: expected a JSON object")
}
value = parsed
case "array":
var parsed []any
if strings.HasPrefix(trimmed, "[") {
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to array: %w", err)
}
} else if trimmed != "" {
for _, item := range strings.Split(trimmed, ",") {
if item = strings.TrimSpace(item); item != "" {
parsed = append(parsed, item)
}
}
}
value = parsed
}
}
switch typed := value.(type) {
case map[string]any:
properties, _ := schema["properties"].(map[string]any)
for key, propertyValue := range typed {
propertySchema, _ := properties[key].(map[string]any)
if len(propertySchema) == 0 {
continue
}
coerced, err := coercePluginSchemaValue(propertyValue, propertySchema)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
typed[key] = coerced
}
return typed, nil
case []string:
items := make([]any, len(typed))
for index, item := range typed {
items[index] = item
}
value = items
}
if items, ok := value.([]any); ok {
itemSchema, _ := schema["items"].(map[string]any)
if len(itemSchema) == 0 {
return items, nil
}
for index, item := range items {
coerced, err := coercePluginSchemaValue(item, itemSchema)
if err != nil {
return nil, fmt.Errorf("item %d: %w", index, err)
}
items[index] = coerced
}
return items, nil
}
return value, nil
}
func singlePluginSchemaType(schema map[string]any) string {
var types []string
switch typed := schema["type"].(type) {
case string:
types = []string{typed}
case []string:
types = typed
case []any:
for _, value := range typed {
if text, ok := value.(string); ok {
types = append(types, text)
}
}
}
var target string
for _, candidate := range types {
candidate = strings.TrimSpace(candidate)
if candidate == "" || candidate == "null" {
continue
}
if target != "" && target != candidate {
return ""
}
target = candidate
}
return target
}
@@ -0,0 +1,229 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"errors"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginToolInputSchemaMatchesTrimmedName(t *testing.T) {
want := map[string]any{"type": "object"}
tools := transport.ToolsListResult{Tools: []transport.ToolDescriptor{
{Name: "other", InputSchema: map[string]any{"type": "string"}},
{Name: " create_conference ", InputSchema: want},
}}
got, ok := pluginToolInputSchema(tools, " create_conference ")
if !ok || !reflect.DeepEqual(got, want) {
t.Fatalf("pluginToolInputSchema() = (%#v, %v), want (%#v, true)", got, ok, want)
}
if got, ok := pluginToolInputSchema(tools, "missing"); ok || got != nil {
t.Fatalf("missing pluginToolInputSchema() = (%#v, %v), want (nil, false)", got, ok)
}
}
func TestNormalizePluginInputParamsCoercesNestedValues(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []string{"enabled"},
"properties": map[string]any{
"enabled": map[string]any{"type": []any{"null", "bool"}},
"count": map[string]any{"type": "int"},
"ratio": map[string]any{"type": "float"},
"settings": map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
"ids": map[string]any{
"type": []string{"array", "null"},
"items": map[string]any{"type": "int"},
},
"labels": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
},
"booleans": map[string]any{
"type": "array",
"items": map[string]any{"type": "bool"},
},
"ambiguous": map[string]any{"type": []string{"string", "int"}},
},
}
params := map[string]any{
"enabled": " true ",
"count": " 7 ",
"ratio": " 2.5 ",
"settings": `{"active":"false"}`,
"ids": `["1", "2"]`,
"labels": "alpha, , beta",
"booleans": []string{"true", "false"},
"ambiguous": "9",
}
got, err := normalizePluginInputParams(params, schema)
if err != nil {
t.Fatalf("normalizePluginInputParams() error = %v", err)
}
want := map[string]any{
"enabled": true,
"count": 7,
"ratio": 2.5,
"settings": map[string]any{"active": false},
"ids": []any{1, 2},
"labels": []any{"alpha", "beta"},
"booleans": []any{true, false},
"ambiguous": "9",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalizePluginInputParams() = %#v, want %#v", got, want)
}
properties := schema["properties"].(map[string]any)
if gotType := properties["enabled"].(map[string]any)["type"].([]any)[1]; gotType != "bool" {
t.Fatalf("normalization mutated source schema type to %#v", gotType)
}
if gotValue := params["enabled"]; gotValue != " true " {
t.Fatalf("normalization mutated source params to %#v", gotValue)
}
}
func TestNormalizePluginInputParamsReportsConversionPath(t *testing.T) {
tests := []struct {
name string
value any
fieldSchema map[string]any
wantText string
}{
{name: "boolean", value: "sometimes", fieldSchema: map[string]any{"type": "bool"}, wantText: "cannot convert"},
{name: "integer", value: "1.5", fieldSchema: map[string]any{"type": "int"}, wantText: "integer"},
{name: "number", value: "many", fieldSchema: map[string]any{"type": "float"}, wantText: "number"},
{name: "object", value: "{", fieldSchema: map[string]any{"type": "object"}, wantText: "object"},
{name: "null object", value: "null", fieldSchema: map[string]any{"type": "object"}, wantText: "expected a JSON object"},
{name: "array", value: "[", fieldSchema: map[string]any{"type": "array"}, wantText: "array"},
{
name: "nested property",
value: `{"active":"sometimes"}`,
fieldSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
wantText: "field: active:",
},
{
name: "array item",
value: "1,not-an-int",
fieldSchema: map[string]any{
"type": "array",
"items": map[string]any{"type": "int"},
},
wantText: "item 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{"field": tt.fieldSchema},
}
_, err := normalizePluginInputParams(map[string]any{"field": tt.value}, schema)
if err == nil {
t.Fatal("normalizePluginInputParams() error = nil, want conversion error")
}
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
appError.Reason != "plugin_input_schema_invalid" {
t.Fatalf("conversion error = %#v, want categorized plugin schema validation error", err)
}
if !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("conversion error = %q, want text %q", err, tt.wantText)
}
})
}
}
func TestNormalizePluginInputParamsRunsSchemaValidation(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []any{"name"},
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
}
if _, err := normalizePluginInputParams(map[string]any{}, schema); err == nil ||
!strings.Contains(err.Error(), "$.name is required") {
t.Fatalf("required-field validation error = %v", err)
}
}
func TestPluginInputSchemaHelperEdges(t *testing.T) {
if got := canonicalPluginInputSchema(nil); got != nil {
t.Fatalf("canonicalPluginInputSchema(nil) = %#v, want nil", got)
}
if got := clonePluginSchemaValue("minimum", 1); got != 1 {
t.Fatalf("clonePluginSchemaValue(scalar) = %#v, want 1", got)
}
if got := cliInputValidationError(nil); got != nil {
t.Fatalf("cliInputValidationError(nil) = %v, want nil", got)
}
if got, err := coercePluginSchemaValue("", map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, []any(nil)) {
t.Fatalf("empty array coercion = (%#v, %v), want nil slice", got, err)
}
items := []any{"unchanged"}
if got, err := coercePluginSchemaValue(items, map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, items) {
t.Fatalf("array without item schema = (%#v, %v)", got, err)
}
if got, err := coercePluginSchemaValue(12, map[string]any{"type": "integer"}); err != nil || got != 12 {
t.Fatalf("non-string scalar coercion = (%#v, %v), want (12, nil)", got, err)
}
unknown := map[string]any{"unknown": "unchanged"}
if got, err := coercePluginSchemaValue(unknown, map[string]any{
"type": "object",
"properties": map[string]any{},
}); err != nil || !reflect.DeepEqual(got, unknown) {
t.Fatalf("unknown property coercion = (%#v, %v), want unchanged map", got, err)
}
tests := []struct {
name string
schema map[string]any
want string
}{
{name: "missing", schema: map[string]any{}, want: ""},
{name: "single string", schema: map[string]any{"type": "integer"}, want: "integer"},
{name: "single string slice", schema: map[string]any{"type": []string{"null", "number"}}, want: "number"},
{name: "any slice", schema: map[string]any{"type": []any{nil, 3, "", "null", "boolean"}}, want: "boolean"},
{name: "ambiguous", schema: map[string]any{"type": []any{"string", "integer"}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := singlePluginSchemaType(tt.schema); got != tt.want {
t.Fatalf("singlePluginSchemaType(%#v) = %q, want %q", tt.schema, got, tt.want)
}
})
}
for raw, want := range map[string]string{
" BOOL ": "boolean",
"Int": "integer",
"FLOAT": "number",
"custom": "custom",
} {
if got := canonicalPluginSchemaType(raw); got != want {
t.Errorf("canonicalPluginSchemaType(%q) = %q, want %q", raw, got, want)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"errors"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginStdioExecutionNormalizesAndValidatesLiveSchema(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("conference/local", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error {
return nil
}
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "create_conference",
InputSchema: map[string]any{
"type": "object",
"required": []any{"title"},
"properties": map[string]any{
"title": map[string]any{"type": "string"},
"capture_speaker": map[string]any{"type": "bool"},
},
"additionalProperties": false,
},
}},
}, nil
}
var calledParams map[string]any
runnerStdioCallTool = func(
_ *transport.StdioClient,
_ context.Context,
_ string,
params map[string]any,
) (transport.ToolCallResult, error) {
calledParams = params
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{
CanonicalProduct: "conference-local",
Tool: "create_conference",
Params: map[string]any{
"title": "schema validation",
"capture_speaker": "true",
},
}
result, err := runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
if err != nil {
t.Fatalf("stdio plugin execution: %v", err)
}
wantParams := map[string]any{
"title": "schema validation",
"capture_speaker": true,
}
if !reflect.DeepEqual(calledParams, wantParams) ||
!reflect.DeepEqual(result.Invocation.Params, wantParams) {
t.Fatalf("normalized wire params = %#v, result = %#v", calledParams, result.Invocation.Params)
}
calledParams = nil
invocation.Params = map[string]any{"capture_speaker": "true"}
_, err = runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
calledParams != nil {
t.Fatalf("missing required schema validation = %#v, call params = %#v", err, calledParams)
}
}
@@ -0,0 +1,369 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type schemaSourceContextKey struct{}
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
pluginLoads := 0
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
pluginLoads++
return nil
}
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
root := NewSchemaSourceRootCommand(wantContext)
if root.Context() != wantContext {
t.Fatal("Schema source root did not retain the caller context")
}
if pluginLoads != 0 {
t.Fatalf("Schema source root loaded runtime plugins %d times", pluginLoads)
}
}
func TestCollectPluginServerCandidatesSortsAndSkipsInvalidStdio(t *testing.T) {
previousDescriptors := rootPluginDescriptors
previousClients := rootPluginStdioClients
previousDescriptor := rootPluginStdioDescriptor
t.Cleanup(func() {
rootPluginDescriptors = previousDescriptors
rootPluginStdioClients = previousClients
rootPluginStdioDescriptor = previousDescriptor
})
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "first"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "second"}}
wantContext := &plugin.UserContext{UserID: "user", CorpID: "corp"}
client := transport.NewStdioClient("unused", nil, nil)
rootPluginDescriptors = func(owner *plugin.Plugin) []mcptypes.ServerDescriptor {
if owner == first {
return []mcptypes.ServerDescriptor{{Key: "same"}, {Key: " beta "}}
}
return []mcptypes.ServerDescriptor{{Key: "aardvark"}}
}
rootPluginStdioClients = func(owner *plugin.Plugin, gotContext *plugin.UserContext) []plugin.StdioServerClient {
if gotContext != wantContext {
t.Fatalf("stdio user context = %#v, want %#v", gotContext, wantContext)
}
if owner != first {
return nil
}
return []plugin.StdioServerClient{
{Key: "same", Client: client},
{Key: " alpha ", Client: client},
{Key: "invalid", Client: client},
}
}
rootPluginStdioDescriptor = func(_ *plugin.Plugin, stdio plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
if stdio.Key == "invalid" {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{Key: stdio.Key}, true
}
candidates := collectPluginServerCandidates([]*plugin.Plugin{first, second}, wantContext)
if len(candidates) != 5 {
t.Fatalf("candidate count = %d, want 5", len(candidates))
}
gotKeys := make([]string, 0, len(candidates))
gotKinds := make([]string, 0, len(candidates))
for _, candidate := range candidates {
gotKeys = append(gotKeys, candidate.descriptor.Key)
if candidate.stdioClient == nil {
gotKinds = append(gotKinds, "http")
} else {
gotKinds = append(gotKinds, "stdio")
if candidate.stdioClient.Client != client {
t.Fatal("stdio candidate did not retain its client")
}
}
}
if want := []string{" alpha ", " beta ", "same", "same", "aardvark"}; !reflect.DeepEqual(gotKeys, want) {
t.Fatalf("candidate keys = %#v, want %#v", gotKeys, want)
}
if want := []string{"stdio", "http", "http", "stdio", "http"}; !reflect.DeepEqual(gotKinds, want) {
t.Fatalf("candidate transports = %#v, want %#v", gotKinds, want)
}
}
func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) {
isolatePluginRuntime(t)
blank := mcptypes.ServerDescriptor{
Key: " ",
CLI: mcptypes.CLIOverlay{
ID: " ",
Command: " ",
Aliases: []string{"", " "},
},
}
if claims := pluginDescriptorIdentityClaims(blank); len(claims) != 0 {
t.Fatalf("blank descriptor claims = %#v, want none", claims)
}
if rootName := pluginDescriptorRootName(blank); rootName != "" {
t.Fatalf("blank descriptor root = %q", rootName)
}
owner := &plugin.Plugin{Manifest: plugin.Manifest{Name: "blank"}}
accepted := selectPluginServerCandidates(
&cobra.Command{Use: "dws"},
[]pluginServerCandidate{
{owner: owner, descriptor: mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}},
{owner: owner, descriptor: blank},
},
)
if len(accepted) != 1 {
t.Fatalf("blank descriptor candidates = %#v, want one accepted candidate", accepted)
}
if distributionRootOwns(nil, "visible") {
t.Fatal("nil root claimed a command")
}
root := &cobra.Command{Use: "dws"}
visible := &cobra.Command{Use: "visible", Aliases: []string{" visible-alias "}}
hiddenFallback := &cobra.Command{Use: "conference", Hidden: true}
hiddenOwned := &cobra.Command{Use: "hidden-owned", Hidden: true}
pluginOwned := &cobra.Command{Use: "plugin-owned", Aliases: []string{"plugin-alias"}}
cmdutil.MarkPluginSource(pluginOwned)
root.AddCommand(visible, hiddenFallback, hiddenOwned, pluginOwned)
for _, name := range []string{"visible", "visible-alias", "hidden-owned"} {
if !distributionRootOwns(root, name) {
t.Errorf("distribution root did not claim %q", name)
}
}
for _, name := range []string{"conference", "plugin-owned", "plugin-alias", "missing"} {
if distributionRootOwns(root, name) {
t.Errorf("distribution root unexpectedly claimed %q", name)
}
}
}
func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) {
isolatePluginRuntime(t)
SetDynamicServers([]mcptypes.ServerDescriptor{
{
Key: "conference",
Endpoint: "https://example.com/conference/mcp",
CLI: mcptypes.CLIOverlay{ID: "conference"},
},
{
Key: "chat",
Endpoint: "https://example.com/chat/mcp",
CLI: mcptypes.CLIOverlay{ID: "chat"},
},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "conference", Hidden: true})
distributionProducts := DirectRuntimeProductIDs()
conferenceDescriptor := mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"},
}
if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) {
t.Fatal("replaceable fallback identity blocked plugin server selection")
}
chatDescriptor := mcptypes.ServerDescriptor{
Key: "chat-local",
DisplayName: "chat/chat-local",
CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"},
}
if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) {
t.Fatal("non-replaceable distribution product no longer conflicts")
}
reservedDescriptor := mcptypes.ServerDescriptor{
Key: "auth-local",
DisplayName: "auth/auth-local",
CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"},
}
if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) {
t.Fatal("reserved command name no longer conflicts")
}
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}}
accepted := selectPluginServerCandidates(root, []pluginServerCandidate{
{owner: first, descriptor: conferenceDescriptor},
{
owner: second,
descriptor: mcptypes.ServerDescriptor{
Key: "conference-other",
DisplayName: "other/conference-other",
CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"},
},
},
})
if len(accepted) != 1 {
t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted))
}
if accepted[0].owner != first {
t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name)
}
}
func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "taken"})
command := &cobra.Command{
Use: "extension",
Aliases: []string{"", "extension", "auth", "taken", "shared", " shared ", " okay "},
}
addPluginCommandsSafe(root, []*cobra.Command{
command,
{Use: "shared"},
{Use: "other", Aliases: []string{"extension"}},
})
if want := []string{"shared", "okay"}; !reflect.DeepEqual(command.Aliases, want) {
t.Fatalf("filtered aliases = %#v, want %#v", command.Aliases, want)
}
if child := findDirectChild(root, "shared"); child != nil {
t.Fatal("an accepted alias was also registered as a plugin primary command")
}
other := findDirectChild(root, "other")
if other == nil || len(other.Aliases) != 0 {
t.Fatalf("later plugin aliases = %#v", other)
}
}
func TestStdioRunnerReportsToolsListFailureAndMissingTool(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("plugin/server", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
toolCalls := 0
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
toolCalls++
return transport.ToolCallResult{}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{CanonicalProduct: "overlay-id", Tool: "wanted"}
listFailure := errors.New("list failed")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{}, listFailure
}
_, err := runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryAPI, "tools/list", "stdio_tools_list_error")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{Tools: []transport.ToolDescriptor{{Name: "other"}}}, nil
}
_, err = runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryValidation, "", "plugin_tool_not_found")
if toolCalls != 0 {
t.Fatalf("tools/call attempts after tools/list failures = %d", toolCalls)
}
}
func TestStdioManifestDescriptorAndRegistrationFailClosed(t *testing.T) {
isolatePluginRuntime(t)
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: "broken-plugin",
MCPServers: map[string]*plugin.MCPServer{
"local": {CLI: json.RawMessage(`{`)},
},
},
}
server := plugin.StdioServerClient{
Key: "local",
Client: transport.NewStdioClient("unused", nil, nil),
}
if descriptor, ok := stdioServerDescriptorFromManifest(p, server); ok || !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid descriptor = (%#v, %v), want zero, false", descriptor, ok)
}
if descriptor := registerStdioServerFromManifest(p, server); !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid registered descriptor = %#v, want zero", descriptor)
}
if _, ok := LookupStdioClient("broken-plugin/local"); ok {
t.Fatal("invalid stdio manifest registered a client")
}
}
func TestLegacyCommandsContinueWhenUserShortcutLoadFails(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(shortcutDir, "broken.yaml"), []byte("version: ["), 0o600); err != nil {
t.Fatal(err)
}
if _, loadErrors := userdef.Load(); len(loadErrors) == 0 {
t.Fatal("malformed shortcut fixture did not fail to load")
}
runner := executor.EchoRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{})
if commands := newLegacyPublicCommands(runner, caller, true); len(commands) == 0 {
t.Fatal("legacy commands were dropped after a user shortcut load error")
}
}
func findDirectChild(root *cobra.Command, name string) *cobra.Command {
for _, command := range root.Commands() {
if command.Name() == name {
return command
}
}
return nil
}
func assertPluginRuntimeError(
t *testing.T,
err error,
wantCategory apperrors.Category,
wantOperation string,
wantReason string,
) {
t.Helper()
var appError *apperrors.Error
if !errors.As(err, &appError) {
t.Fatalf("runtime error = %#v, want structured app error", err)
}
if appError.Category != wantCategory ||
appError.Operation != wantOperation ||
appError.Reason != wantReason {
t.Fatalf("runtime error = %#v, want category=%q operation=%q reason=%q", appError, wantCategory, wantOperation, wantReason)
}
}
+38 -2
View File
@@ -5,6 +5,7 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -12,6 +13,7 @@ import (
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -35,6 +37,11 @@ func isolatePluginRuntime(t *testing.T) {
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
pluginAuthMu.Lock()
previousPluginAuth := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
t.Cleanup(func() {
StopAllStdioClients()
dynamicMu.Lock()
@@ -46,6 +53,9 @@ func isolatePluginRuntime(t *testing.T) {
stdioMu.Lock()
stdioClients = previousStdio
stdioMu.Unlock()
pluginAuthMu.Lock()
pluginAuthRegistry = previousPluginAuth
pluginAuthMu.Unlock()
})
}
@@ -77,14 +87,40 @@ func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
isolatePluginRuntime(t)
marker := t.TempDir() + "/started"
pluginRoot := t.TempDir()
if err := os.WriteFile(pluginRoot+"/overlay.json", []byte(`{
"id":"local",
"command":"lazy-stdio",
"groups":{"health":{"description":"health checks"}},
"toolOverrides":{"ping":{"cliName":"ping","group":"health"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
client := transport.NewStdioClient("/bin/sh", []string{
"-c", fmt.Sprintf("printf started > %q", marker),
}, nil)
p := &plugin.Plugin{
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
Root: t.TempDir(),
Manifest: plugin.Manifest{
Name: "lazy-stdio",
Description: "lazy stdio test",
MCPServers: map[string]*plugin.MCPServer{
"local": {
Type: "stdio",
Command: "unused",
CLI: json.RawMessage(`"overlay.json"`),
},
},
},
Root: pluginRoot,
}
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
root := pluginTestRoot(commands...)
root.SetArgs([]string{"lazy-stdio", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("lazy stdio help: %v", err)
}
requirePluginChild(t, commands[0], "health", "ping")
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("stdio process started during registration: stat error = %v", err)
+34 -44
View File
@@ -14,10 +14,7 @@
package app
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -33,50 +30,26 @@ import (
// When no CLI metadata is present, a minimal overlay keyed by the server
// name is returned so callers can still build an identity descriptor.
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
serverID := sc.Key
overlay := mcptypes.CLIOverlay{
ID: serverID,
Command: serverID,
}
srv, ok := p.Manifest.MCPServers[sc.Key]
if !ok || len(srv.CLI) == 0 {
return overlay
}
cliData := srv.CLI
// A JSON string is interpreted as a relative path to an external
// overlay file (e.g. "overlay.json") anchored at the plugin root.
if len(cliData) > 0 && cliData[0] == '"' {
var cliPath string
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
absPath := filepath.Join(p.Root, cliPath)
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
cliData = fileData
} else {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
}
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.CLIOverlay{
ID: sc.Key,
Command: sc.Key,
Skip: true,
}
}
if err := json.Unmarshal(cliData, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
}
if overlay.ID == "" {
overlay.ID = serverID
}
if overlay.Command == "" {
overlay.Command = serverID
}
return overlay
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
overlay := resolveStdioOverlay(p, sc)
descriptor := mcptypes.ServerDescriptor{
func stdioServerDescriptorFromManifest(
p *plugin.Plugin,
sc plugin.StdioServerClient,
) (mcptypes.ServerDescriptor, bool) {
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -84,13 +57,30 @@ func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClie
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
}, true
}
func registerResolvedStdioServer(
p *plugin.Plugin,
sc plugin.StdioServerClient,
descriptor mcptypes.ServerDescriptor,
) {
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
slog.Debug("plugin: stdio server registered from manifest",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides))
"toolOverrides", len(descriptor.CLI.ToolOverrides))
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
descriptor, ok := stdioServerDescriptorFromManifest(p, sc)
if !ok {
return mcptypes.ServerDescriptor{}
}
registerResolvedStdioServer(p, sc, descriptor)
return descriptor
}
+5 -1
View File
@@ -333,7 +333,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
if err != nil {
return nil, err
}
tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders())
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
if err != nil {
return nil, tokenResolutionError(err)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
+324 -33
View File
@@ -23,6 +23,7 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
@@ -69,7 +70,8 @@ var (
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
rootPluginStdioClients = (*plugin.Plugin).StdioClients
rootRegisterPluginHTTPServer = registerPluginHTTPServer
rootRegisterStdioManifest = registerStdioServerFromManifest
rootPluginStdioDescriptor = stdioServerDescriptorFromManifest
rootRegisterResolvedStdioServer = registerResolvedStdioServer
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
@@ -306,13 +308,28 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return NewRootCommandWithEngine(rootCtx, nil)
return newRootCommandWithEngine(rootCtx, nil, true)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used by Schema generation and command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed embedded Schema.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false)
}
// NewRootCommandWithEngine constructs the root CLI command with an
// optional pipeline engine for input correction. When engine is nil,
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
return newRootCommandWithEngine(rootCtx, engine, true)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -396,16 +413,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller)...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
// be appended on top of the static endpoint registry.
pluginCmds := rootLoadPlugins(engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
@@ -414,6 +424,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
fn(root, caller)
deduplicateCommands(root)
}
if loadRuntimeExtensions {
// Resolve plugins only after the complete distribution command tree is
// present, so endpoint and Cobra conflict checks see PAT and edition
// commands as well as the open-source base.
pluginCmds := rootLoadPlugins(root, engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
}
hideNonDirectRuntimeCommands(root)
configureRootHelp(root)
// Set custom flag error handler for better UX
@@ -631,12 +650,17 @@ var reservedCommands = map[string]bool{
"schema": true, "mcp": true, "help": true,
}
var replaceablePluginFallbacks = map[string]bool{
"conference": true,
}
// addPluginCommandsSafe registers plugin commands with conflict detection.
//
// Rules:
// - Plugin vs reserved (auth/plugin/cache/...) → reject, warn
// - Plugin vs plugin (same name) → reject later one, warn
// - Plugin vs Market dynamic command → allow, plugin wins
// - Plugin vs hidden compatibility fallback → allow, plugin wins
// - Plugin vs visible distribution command → reject, warn
func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
// Build index of existing commands before plugin registration.
existing := make(map[string]bool)
@@ -664,17 +688,47 @@ func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
}
pluginSeen[name] = true
// Rule 3: plugin vs Market — plugin wins, remove the old one.
// An alias must not bypass the same protections applied to primary
// plugin command names or shadow another root command.
filteredAliases := make([]string, 0, len(cmd.Aliases))
for _, rawAlias := range cmd.Aliases {
alias := strings.TrimSpace(rawAlias)
if alias == "" || alias == name || reservedCommands[alias] ||
existing[alias] || pluginSeen[alias] {
if alias != "" {
slog.Warn("plugin: command alias conflicts with an existing command, skipping",
"command", name, "alias", alias)
}
continue
}
pluginSeen[alias] = true
filteredAliases = append(filteredAliases, alias)
}
cmd.Aliases = filteredAliases
// Rule 3: an installed plugin may replace a hidden compatibility
// fallback (for example conference), but never a visible distribution
// command that participates in the reviewed base interface.
if existing[name] {
for _, old := range root.Commands() {
if old.Name() == name {
if !old.Hidden || !replaceablePluginFallbacks[name] ||
cmdutil.IsPluginSourced(old) {
slog.Warn("plugin: command conflicts with a visible distribution command, skipping",
"command", name)
cmd = nil
break
}
root.RemoveCommand(old)
slog.Debug("plugin: overriding Market command",
slog.Debug("plugin: overriding hidden compatibility command",
"command", name)
break
}
}
}
if cmd == nil {
continue
}
root.AddCommand(cmd)
}
@@ -811,7 +865,21 @@ func CloseFileLogger() {
// loadPlugins registers versioned plugin manifests, stdio clients, hooks, and
// skills. It deliberately does not initialize MCP transports or call
// tools/list while constructing the command tree.
func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
type pluginServerCandidate struct {
owner *plugin.Plugin
order int
descriptor mcptypes.ServerDescriptor
stdioClient *plugin.StdioServerClient
}
type pluginIdentityOwner struct {
plugin *plugin.Plugin
serverKey string
rootName string
shareable bool
}
func loadPlugins(root *cobra.Command, engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
pluginLoader := plugin.NewLoader(RawVersion())
// 0a. Inject plugin config values from settings.json as environment
@@ -838,25 +906,34 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
// 2. Load dev plugins (registered via `dws plugin dev`)
devPlugins := rootPluginLoadDev(pluginLoader)
sortPluginsForRegistration(userPlugins)
sortPluginsForRegistration(devPlugins)
allPlugins := append(userPlugins, devPlugins...)
descriptorsByPlugin := make(map[*plugin.Plugin][]mcptypes.ServerDescriptor, len(allPlugins))
// 3. Register HTTP descriptors and authentication from the manifest.
for _, p := range allPlugins {
for _, srv := range rootPluginDescriptors(p) {
rootRegisterPluginHTTPServer(srv)
// 3. Resolve every descriptor once, then choose identity winners before
// mutating endpoint, auth, or stdio-client registries. This keeps the
// visible command and its transport owned by the same plugin.
candidates := collectPluginServerCandidates(allPlugins, userCtx)
accepted := selectPluginServerCandidates(root, candidates)
for _, candidate := range accepted {
if candidate.stdioClient != nil {
rootRegisterResolvedStdioServer(
candidate.owner,
*candidate.stdioClient,
candidate.descriptor,
)
} else {
rootRegisterPluginHTTPServer(candidate.descriptor)
}
descriptorsByPlugin[candidate.owner] = append(
descriptorsByPlugin[candidate.owner],
candidate.descriptor,
)
}
// 4. Register stdio descriptors and unstarted clients. The subprocess is
// started and initialized only when a command is actually executed.
for _, p := range allPlugins {
for _, sc := range rootPluginStdioClients(p, userCtx) {
rootRegisterStdioManifest(p, sc)
}
}
// 5. Register plugin hooks into pipeline engine
// 4. Register plugin hooks into pipeline engine
if engine != nil {
for _, p := range allPlugins {
hooksCfg, err := rootPluginLoadHooks(p)
@@ -874,7 +951,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
}
}
// 7. Sync plugin skills to agent directories
// 5. Sync plugin skills to agent directories
rootPluginSyncSkills(allPlugins)
if len(allPlugins) > 0 {
@@ -884,11 +961,228 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
)
}
return nil
var pluginCommands []*cobra.Command
for _, p := range allPlugins {
// Build each plugin independently. addPluginCommandsSafe deliberately
// resolves cross-plugin root conflicts with first-plugin-wins semantics.
pluginCommands = append(pluginCommands, buildPluginCommands(descriptorsByPlugin[p], runner, root)...)
}
return pluginCommands
}
func sortPluginsForRegistration(plugins []*plugin.Plugin) {
sort.SliceStable(plugins, func(i, j int) bool {
left := strings.TrimSpace(plugins[i].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[i].Root)
right := strings.TrimSpace(plugins[j].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[j].Root)
return left < right
})
}
func collectPluginServerCandidates(
plugins []*plugin.Plugin,
userCtx *plugin.UserContext,
) []pluginServerCandidate {
var candidates []pluginServerCandidate
for order, owner := range plugins {
for _, descriptor := range rootPluginDescriptors(owner) {
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
})
}
for _, stdioClient := range rootPluginStdioClients(owner, userCtx) {
descriptor, ok := rootPluginStdioDescriptor(owner, stdioClient)
if !ok {
continue
}
clientCopy := stdioClient
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
stdioClient: &clientCopy,
})
}
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].order != candidates[j].order {
return candidates[i].order < candidates[j].order
}
left := strings.TrimSpace(candidates[i].descriptor.Key)
right := strings.TrimSpace(candidates[j].descriptor.Key)
if left != right {
return left < right
}
return candidates[i].stdioClient == nil && candidates[j].stdioClient != nil
})
return candidates
}
func selectPluginServerCandidates(
root *cobra.Command,
candidates []pluginServerCandidate,
) []pluginServerCandidate {
distributionProducts := DirectRuntimeProductIDs()
owners := make(map[string]pluginIdentityOwner)
for identity := range distributionProducts {
if replaceablePluginFallbacks[identity] {
continue
}
owners[identity] = pluginIdentityOwner{serverKey: "distribution"}
}
accepted := make([]pluginServerCandidate, 0, len(candidates))
for _, candidate := range candidates {
descriptor := candidate.descriptor
if descriptor.CLI.Skip {
continue
}
if reason := unsupportedPluginDescriptor(root, descriptor); reason != "" {
slog.Warn("plugin: descriptor CLI semantics are unsupported, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"field", reason)
continue
}
if pluginDescriptorConflictsWithDistribution(root, descriptor, distributionProducts) {
continue
}
claims := pluginDescriptorIdentityClaims(descriptor)
conflict := ""
for identity, shareable := range claims {
existing, exists := owners[identity]
if !exists {
continue
}
rootName := pluginDescriptorRootName(descriptor)
if shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
conflict = identity
break
}
if conflict != "" {
slog.Warn("plugin: descriptor identity already owned, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"identity", conflict)
continue
}
rootName := pluginDescriptorRootName(descriptor)
for identity, shareable := range claims {
if existing, exists := owners[identity]; exists &&
shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
owners[identity] = pluginIdentityOwner{
plugin: candidate.owner,
serverKey: descriptor.Key,
rootName: rootName,
shareable: shareable,
}
}
accepted = append(accepted, candidate)
}
return accepted
}
func pluginDescriptorIdentityClaims(descriptor mcptypes.ServerDescriptor) map[string]bool {
claims := make(map[string]bool)
canonicalID := firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key)
if canonicalID != "" {
claims[canonicalID] = false
}
for _, identity := range append(
[]string{pluginDescriptorRootName(descriptor)},
descriptor.CLI.Aliases...,
) {
identity = strings.TrimSpace(identity)
if identity == "" {
continue
}
if _, exists := claims[identity]; !exists {
claims[identity] = true
}
}
return claims
}
func pluginDescriptorRootName(descriptor mcptypes.ServerDescriptor) string {
return firstNonEmptyPluginString(
descriptor.CLI.Command,
descriptor.CLI.ID,
descriptor.Key,
)
}
func pluginDescriptorConflictsWithDistribution(
root *cobra.Command,
descriptor mcptypes.ServerDescriptor,
distributionProducts map[string]bool,
) bool {
candidates := append(
[]string{
firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key),
pluginDescriptorRootName(descriptor),
},
descriptor.CLI.Aliases...,
)
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if !reservedCommands[candidate] && replaceablePluginFallbacks[candidate] {
// The distribution ships only a hidden compatibility fallback for
// this name; plugins may claim it and the later command merge in
// addPluginCommandsSafe still rejects visible non-fallback owners.
continue
}
if reservedCommands[candidate] ||
distributionProducts[candidate] ||
distributionRootOwns(root, candidate) {
slog.Warn("plugin: descriptor conflicts with a distribution command, skipping",
"plugin", descriptor.DisplayName,
"server", descriptor.Key,
"identity", candidate)
return true
}
}
return false
}
func distributionRootOwns(root *cobra.Command, name string) bool {
if root == nil {
return false
}
for _, command := range root.Commands() {
if cmdutil.IsPluginSourced(command) {
continue
}
if command.Name() == name {
if command.Hidden && replaceablePluginFallbacks[name] {
return false
}
return true
}
for _, alias := range command.Aliases {
if strings.TrimSpace(alias) == name {
return true
}
}
}
return false
}
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
AppendDynamicServer(srv)
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
ClearPluginAuth(productID)
if len(srv.AuthHeaders) > 0 {
registerPluginAuthFromHeaders(srv)
}
@@ -917,10 +1211,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
host := parsed.Hostname()
trustedDomains = []string{host, "*." + host}
}
productID := strings.TrimSpace(srv.CLI.ID)
if productID == "" {
productID = srv.Key
}
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
RegisterPluginAuth(productID, &PluginAuth{
Token: authToken,
ExtraHeaders: extraHeaders,
+33 -8
View File
@@ -75,7 +75,7 @@ func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
})
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
}
preRunCalled := false
@@ -236,7 +236,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
oldDescriptors := rootPluginDescriptors
oldStdioClients := rootPluginStdioClients
oldHTTP := rootRegisterPluginHTTPServer
oldStdio := rootRegisterStdioManifest
oldStdioDescriptor := rootPluginStdioDescriptor
oldStdioRegister := rootRegisterResolvedStdioServer
oldHooks := rootPluginLoadHooks
oldSync := rootPluginSyncSkills
oldToken := rootAuthLoadTokenData
@@ -247,7 +248,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
rootPluginDescriptors = oldDescriptors
rootPluginStdioClients = oldStdioClients
rootRegisterPluginHTTPServer = oldHTTP
rootRegisterStdioManifest = oldStdio
rootPluginStdioDescriptor = oldStdioDescriptor
rootRegisterResolvedStdioServer = oldStdioRegister
rootPluginLoadHooks = oldHooks
rootPluginSyncSkills = oldSync
rootAuthLoadTokenData = oldToken
@@ -264,9 +266,17 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
if p == p1 {
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{
Key: "http", Endpoint: "https://example.test",
CLI: mcptypes.CLIOverlay{
ID: "http", Command: "one-http",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"ping": {CLIName: "ping"},
},
},
}}
}
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{Key: p.Manifest.Name + "-no-cli", Endpoint: "https://example.test"}}
}
client := transport.NewStdioClient("ignored", nil, nil)
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
@@ -278,9 +288,23 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
httpCount := 0
stdioCount := 0
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
rootPluginStdioDescriptor = func(*plugin.Plugin, plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
return mcptypes.ServerDescriptor{
Key: "local",
CLI: mcptypes.CLIOverlay{
ID: "local", Command: "one-stdio",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"pong": {CLIName: "pong"},
},
},
}, true
}
rootRegisterResolvedStdioServer = func(
*plugin.Plugin,
plugin.StdioServerClient,
mcptypes.ServerDescriptor,
) {
stdioCount++
return mcptypes.ServerDescriptor{}
}
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
switch p {
@@ -294,7 +318,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
synced := false
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
got := loadPlugins(nil, pipeline.NewEngine(), runnerCoverageFallback{})
if len(got) != 2 || got[0].Name() != "one-http" || got[1].Name() != "one-stdio" {
t.Fatalf("loaded plugin commands = %#v", got)
}
if httpCount != 3 || stdioCount != 1 || !synced {
+101 -57
View File
@@ -168,6 +168,7 @@ var (
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
runnerStdioListTools = (*transport.StdioClient).ListTools
runnerStdioCallTool = (*transport.StdioClient).CallTool
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
@@ -235,7 +236,9 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
// ~70ms on macOS; starting it here lets the load overlap with endpoint
// resolution and catalog loading below.
if prefetchToken {
go runnerGetCachedRuntimeToken(ctx)
go func() {
_, _ = runnerGetCachedRuntimeToken(ctx)
}()
}
if shouldUseDirectRuntime(invocation) {
@@ -483,7 +486,7 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
if IsStdioEndpoint(endpoint) {
return r.executeStdioInvocation(ctx, invocation)
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
}
// Constructing the Cobra tree is also used for help, schema, and command
@@ -534,8 +537,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
authToken := ""
if hasPluginAuth {
authToken = pluginAuth.Token
} else {
authToken = r.resolveAuthToken(ctx)
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
var tokenErr error
authToken, tokenErr = r.resolveAuthToken(ctx)
if tokenErr != nil {
return executor.Result{}, tokenResolutionError(tokenErr)
}
}
var timeoutSec int
@@ -617,6 +624,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, err, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, err, retryErr)
}
return result, retryErr
}
runnerCaptureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
@@ -625,9 +638,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
RecordTiming(ctx, "mcp_call", time.Since(callStart))
if err != nil {
if isAuthError(err) {
if isRefreshableTransportAuthError(err) {
if fn := edition.Get().OnAuthError; fn != nil {
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, overrideErr, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, err, retryErr)
}
return result, retryErr
}
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
return executor.Result{}, overrideErr
}
@@ -652,6 +671,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, editionErr, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, editionErr, retryErr)
}
return result, retryErr
}
return executor.Result{}, editionErr
}
}
@@ -672,6 +697,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// patterns (PAT permission, gateway-auth) before generic handling.
if classify := edition.Get().ClassifyToolResult; classify != nil {
if hookErr := classify(callResult.Content); hookErr != nil {
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, hookErr, hasPluginAuth); handled {
if retryErr != nil {
runnerCaptureRuntimeFailure(invocation, hookErr, retryErr)
}
return result, retryErr
}
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
return executor.Result{}, hookErr
}
@@ -736,6 +767,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// subprocess instead of the HTTP transport. This is used for plugin stdio
// servers whose endpoints use the stdio:// scheme.
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
return r.executeStdioInvocationAtEndpoint(ctx, "", invocation)
}
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
ctx context.Context,
endpoint string,
invocation executor.Invocation,
) (executor.Result, error) {
if invocation.DryRun {
return executor.Result{
Invocation: invocation,
@@ -748,10 +787,14 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
}, nil
}
client, ok := LookupStdioClient(invocation.CanonicalProduct)
lookupKey := strings.Trim(strings.TrimPrefix(strings.TrimSpace(endpoint), stdioEndpointScheme), "/")
if lookupKey == "" {
lookupKey = invocation.CanonicalProduct
}
client, ok := LookupStdioClient(lookupKey)
if !ok {
return executor.Result{}, apperrors.NewInternal(
fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct))
fmt.Sprintf("stdio client not found for %q", lookupKey))
}
callCtx := ctx
@@ -768,6 +811,27 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
)
}
tools, err := runnerStdioListTools(client, callCtx)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
fmt.Sprintf("stdio tools/list failed: %v", err),
apperrors.WithOperation("tools/list"),
apperrors.WithReason("stdio_tools_list_error"),
)
}
schema, ok := pluginToolInputSchema(tools, invocation.Tool)
if !ok {
return executor.Result{}, apperrors.NewValidation(
fmt.Sprintf("plugin tool %q is not declared by tools/list", invocation.Tool),
apperrors.WithReason("plugin_tool_not_found"),
)
}
normalizedParams, err := normalizePluginInputParams(invocation.Params, schema)
if err != nil {
return executor.Result{}, err
}
invocation.Params = normalizedParams
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
@@ -796,67 +860,49 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
}, nil
}
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string {
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
explicitToken := ""
if r != nil && r.globalFlags != nil {
explicitToken = r.globalFlags.Token
}
if token := strings.TrimSpace(explicitToken); token != "" {
return token
}
if tp := edition.Get().TokenProvider; tp != nil {
token, _ := tp(ctx, func() (string, error) {
return resolveAccessTokenFromDir(ctx, defaultConfigDir())
})
return token
}
return getCachedRuntimeToken(ctx)
return resolveRuntimeAuthToken(ctx, explicitToken)
}
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
if token := strings.TrimSpace(explicitToken); token != "" {
return token
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
if err != nil {
return "", err
}
// Use cached token to avoid repeated Keychain access (~70ms per call)
return getCachedRuntimeToken(ctx)
return snapshot.AccessToken, nil
}
// Cached token state for process lifetime
var (
cachedRuntimeTokenMu sync.Mutex
cachedRuntimeTokens = map[string]string{}
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
if cacheKey == "" {
cacheKey = "__default__"
}
cachedRuntimeTokenMu.Lock()
if token := cachedRuntimeTokens[cacheKey]; token != "" {
cachedRuntimeTokenMu.Unlock()
return token
}
cachedRuntimeTokenMu.Unlock()
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
// cache itself lives exclusively in TokenManager.
func getCachedRuntimeToken(ctx context.Context) (string, error) {
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
return resolveRuntimeAuthToken(ctx, "")
}
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
func tokenResolutionError(err error) error {
if err == nil {
return nil
}
if token == "" {
return ""
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
cachedRuntimeTokenMu.Lock()
cachedRuntimeTokens[cacheKey] = token
cachedRuntimeTokenMu.Unlock()
return token
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
return apperrors.NewAuth(
"未登录,请先执行 dws auth login",
apperrors.WithReason("not_authenticated"),
apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
apperrors.WithActions("dws auth login"),
apperrors.WithCause(err),
)
}
// Keychain, parse, permission, lock, and refresh failures are real local or
// network errors. Preserve their cause instead of disguising them as logout.
return fmt.Errorf("resolve access token: %w", err)
}
// generateExecutionID returns a random 16-char hex string used to correlate
@@ -871,9 +917,7 @@ func generateExecutionID() string {
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenMu.Lock()
defer cachedRuntimeTokenMu.Unlock()
cachedRuntimeTokens = map[string]string{}
runtimeTokenManager.Invalidate()
}
func newRuntimeContentScanner() safety.Scanner {
+29 -9
View File
@@ -50,9 +50,9 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
prefetched := make(chan struct{}, 1)
runnerGetCachedRuntimeToken = func(context.Context) string {
runnerGetCachedRuntimeToken = func(context.Context) (string, error) {
prefetched <- struct{}{}
return ""
return "", nil
}
r := &runtimeRunner{
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
@@ -197,7 +197,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
return nil
}
authErr := apperrors.NewAuth("expired")
authErr := apperrors.NewAuth("expired", apperrors.WithReason("http_401"))
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, authErr
}
@@ -290,10 +290,12 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
oldStdioInit := runnerStdioEnsureInitialized
oldStdioList := runnerStdioListTools
oldStdioCall := runnerStdioCallTool
oldEdition := edition.Get()
t.Cleanup(func() {
runnerStdioEnsureInitialized = oldStdioInit
runnerStdioListTools = oldStdioList
runnerStdioCallTool = oldStdioCall
edition.Override(oldEdition)
StopAllStdioClients()
@@ -309,6 +311,14 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
t.Fatalf("stdio initialize error = %v", err)
}
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "tool",
InputSchema: map[string]any{"type": "object"},
}},
}, nil
}
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, wantErr
}
@@ -327,21 +337,31 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio success = %#v, %v", got, err)
}
RegisterStdioClient("plugin/server-key", client)
overlayIDInvocation := inv
overlayIDInvocation.CanonicalProduct = "overlay-id"
if got, err := r.executeInvocation(
context.Background(),
"stdio://plugin/server-key",
overlayIDInvocation,
); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio endpoint-key lookup = %#v, %v", got, err)
}
r.globalFlags.Token = " explicit "
if got := r.resolveAuthToken(context.Background()); got != "explicit" {
t.Fatalf("explicit auth token = %q", got)
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
t.Fatalf("explicit auth token = %q, %v", got, err)
}
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
_, _ = fallback()
return "provided", nil
}})
r.globalFlags.Token = ""
if got := r.resolveAuthToken(context.Background()); got != "provided" {
t.Fatalf("provided auth token = %q", got)
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "provided" {
t.Fatalf("provided auth token = %q, %v", got, err)
}
if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" {
t.Fatalf("runtime explicit token = %q", got)
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
t.Fatalf("runtime explicit token = %q, %v", got, err)
}
t.Setenv(envDWSChannel, "channel")
+1 -1
View File
@@ -14,7 +14,7 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
if err != nil {
t.Fatal(err)
}
root := NewRootCommand()
root := NewSchemaSourceRootCommand()
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
t.Fatal(err)
}
+30 -26
View File
@@ -17,6 +17,7 @@ import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
@@ -36,25 +37,25 @@ import (
)
var (
skillLoadAccessToken = loadSkillAccessToken
skillDownloadToTmp = downloadSkillToTmpDir
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
skillNewRequest = http.NewRequestWithContext
skillLoadTokenData = authpkg.LoadTokenData
skillResolveTargetPath = resolveSkillTargetPath
skillFetchDownloadInfo = fetchSkillDownloadInfo
skillDownloadFile = downloadSkillFile
skillExtractZip = extractSkillZip
skillUserHomeDir = os.UserHomeDir
skillMkdirTemp = os.MkdirTemp
skillCreate = os.Create
skillCreateTemp = os.CreateTemp
skillRemoveAll = os.RemoveAll
skillRemove = os.Remove
skillMkdirAll = os.MkdirAll
skillOpenFile = os.OpenFile
skillCopy = io.Copy
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
skillLoadAccessToken = loadSkillAccessToken
skillDownloadToTmp = downloadSkillToTmpDir
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
skillNewRequest = http.NewRequestWithContext
skillResolveAccessToken = ResolveAuxiliaryAccessToken
skillResolveTargetPath = resolveSkillTargetPath
skillFetchDownloadInfo = fetchSkillDownloadInfo
skillDownloadFile = downloadSkillFile
skillExtractZip = extractSkillZip
skillUserHomeDir = os.UserHomeDir
skillMkdirTemp = os.MkdirTemp
skillCreate = os.Create
skillCreateTemp = os.CreateTemp
skillRemoveAll = os.RemoveAll
skillRemove = os.Remove
skillMkdirAll = os.MkdirAll
skillOpenFile = os.OpenFile
skillCopy = io.Copy
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
)
func init() {
@@ -296,7 +297,7 @@ func newSkillAddHintCommand() *cobra.Command {
func runSkillGet(cmd *cobra.Command, args []string) error {
skillID, _ := cmd.Flags().GetString("skill-id")
accessToken, err := skillLoadAccessToken()
accessToken, err := skillLoadAccessToken(cmd.Context())
if err != nil {
return err
}
@@ -319,7 +320,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
if source == "" {
source, _ = cmd.Flags().GetString("scopes")
}
accessToken, err := skillLoadAccessToken()
accessToken, err := skillLoadAccessToken(cmd.Context())
if err != nil {
return err
}
@@ -388,7 +389,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
}
accessToken, err := skillLoadAccessToken()
accessToken, err := skillLoadAccessToken(cmd.Context())
if err != nil {
return err
}
@@ -441,13 +442,16 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
return nil
}
func loadSkillAccessToken() (string, error) {
func loadSkillAccessToken(ctx context.Context) (string, error) {
configDir := defaultConfigDir()
tokenData, err := skillLoadTokenData(configDir)
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
token, err := skillResolveAccessToken(ctx, configDir, "")
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
return "", skillAuthError()
}
return tokenData.AccessToken, nil
if err != nil {
return "", fmt.Errorf("resolve skill access token: %w", err)
}
return token, nil
}
func skillAuthError() error {
@@ -57,11 +57,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
})
fail := errors.New("failure")
cmd := skillCoverageCommand()
skillLoadAccessToken = func() (string, error) { return "", fail }
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
t.Fatalf("skill get auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find request failure should propagate")
@@ -72,11 +72,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
t.Fatalf("skill get download error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "", fail }
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
t.Fatalf("skill find auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
if err := runSkillFind(cmd, nil); err == nil {
t.Fatal("skill find network failure should propagate")
@@ -111,11 +111,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
t.Fatal("invalid skill target should fail")
}
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
skillLoadAccessToken = func() (string, error) { return "", fail }
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill add auth error = %v", err)
}
skillLoadAccessToken = func() (string, error) { return "token", nil }
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
t.Fatalf("skill info error = %v", err)
@@ -152,25 +152,35 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) {
oldHTTP := skillHTTPDo
oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData
oldNewRequest, oldResolveToken := skillNewRequest, skillResolveAccessToken
oldHome := skillUserHomeDir
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
t.Cleanup(func() {
skillHTTPDo = oldHTTP
skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken
skillNewRequest, skillResolveAccessToken = oldNewRequest, oldResolveToken
skillUserHomeDir = oldHome
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
})
fail := errors.New("failure")
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
if _, err := loadSkillAccessToken(); err == nil {
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
}
if _, err := loadSkillAccessToken(context.Background()); err == nil {
t.Fatal("invalid skill access token succeeded")
}
skillLoadTokenData = oldLoadToken
canceled, cancel := context.WithCancel(context.Background())
cancel()
skillResolveAccessToken = func(ctx context.Context, _, _ string) (string, error) {
return "", ctx.Err()
}
if _, err := loadSkillAccessToken(canceled); !errors.Is(err, context.Canceled) {
t.Fatalf("skill token cancellation = %v", err)
}
skillResolveAccessToken = oldResolveToken
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
t.Fatal("download-info request failure should propagate")
+9 -13
View File
@@ -18,7 +18,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -397,9 +396,11 @@ func TestSkillInstallRequiresAuth(t *testing.T) {
configDir := filepath.Join(tempDir, "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Cleanup(CloseFileLogger)
originalLoadToken := skillLoadTokenData
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") }
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
originalResolveToken := skillResolveAccessToken
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
return "", authpkg.ErrTokenDataNotFound
}
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
// Ensure the config directory exists but has no token
if err := os.MkdirAll(configDir, 0755); err != nil {
@@ -679,16 +680,11 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "config")
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Cleanup(CloseFileLogger)
originalLoadToken := skillLoadTokenData
skillLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
AccessToken: "test-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
}, nil
originalResolveToken := skillResolveAccessToken
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
return "test-token", nil
}
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
var gotSources []string
var gotScopes []string
+10
View File
@@ -52,6 +52,10 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.TestNamespaceEnv + ": " + err.Error())
}
if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set DWS_CONFIG_DIR: " + err.Error())
@@ -79,6 +83,12 @@ func TestMain(m *testing.M) {
StopAllStdioClients()
CloseAuditSink()
CloseFileLogger()
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
fmt.Fprintf(os.Stderr, "internal/app keychain cleanup: %v\n", err)
if code == 0 {
code = 1
}
}
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err)
if code == 0 {
@@ -0,0 +1,70 @@
package auth
import (
"context"
"errors"
"testing"
"time"
)
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *testing.T) {
oldLoad := oauthLoadToken
want := errors.New("keychain permission denied")
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
t.Cleanup(func() { oauthLoadToken = oldLoad })
_, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
}
if errors.Is(err, ErrTokenDataNotFound) {
t.Fatalf("load failure was misclassified as missing credentials: %v", err)
}
}
func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) {
oldLoad := oauthLoadToken
want := errors.New("keychain permission denied")
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
t.Cleanup(func() { oauthLoadToken = oldLoad })
_, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false)
if !errors.Is(err, want) {
t.Fatalf("error = %v, want cause %v", err, want)
}
}
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotReturnsExpiryMetadata(t *testing.T) {
oldLoad := oauthLoadToken
expiresAt := time.Now().Add(time.Hour)
oauthLoadToken = func(string) (*TokenData, error) {
return &TokenData{AccessToken: "token", ExpiresAt: expiresAt}, nil
}
t.Cleanup(func() { oauthLoadToken = oldLoad })
snapshot, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if snapshot.AccessToken != "token" || !snapshot.ExpiresAt.Equal(expiresAt) {
t.Fatalf("snapshot = %#v", snapshot)
}
}
func TestCrossPlatformCoverageTokenMarkerRevisionChangesOnEveryPublication(t *testing.T) {
configDir := t.TempDir()
if err := WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
first, present, err := ReadTokenMarkerRevision(configDir)
if err != nil || !present || first == "" {
t.Fatalf("first marker = %q, %v, %v", first, present, err)
}
if err := WriteTokenMarker(configDir); err != nil {
t.Fatal(err)
}
second, present, err := ReadTokenMarkerRevision(configDir)
if err != nil || !present || second == "" || second == first {
t.Fatalf("second marker = %q, %v, %v; first=%q", second, present, err, first)
}
}
+1 -1
View File
@@ -722,6 +722,6 @@ func isInvalidGrantError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
msg := strings.ToLower(err.Error() + " " + httpStatusResponseBody(err))
return strings.Contains(msg, "invalid_grant") || (strings.Contains(msg, "code") && strings.Contains(msg, "expired"))
}
+233 -1
View File
@@ -13,7 +13,50 @@
package auth
import "time"
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const rejectedTokenRefreshFailureCooldown = 3 * time.Second
type rejectedTokenRefreshKey struct {
configDir string
profile string
tokenDigest [sha256.Size]byte
}
type rejectedTokenRefreshCall struct {
done chan struct{}
participants int
token string
err error
}
type rejectedTokenRefreshFailure struct {
at time.Time
err error
}
var rejectedTokenRefreshCoordinator = struct {
sync.Mutex
inFlight map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall
failures map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure
now func() time.Time
}{
inFlight: make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall),
failures: make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure),
now: time.Now,
}
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
// instant (preserving access_token and refresh_token), and writes it back. The
@@ -39,3 +82,192 @@ func MarkAccessTokenStale(configDir string) error {
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
return SaveTokenData(configDir, data)
}
// ForceRefreshRejectedToken refreshes rejectedAccessToken only while it is
// still the credential stored for the active profile. The compare and refresh
// run under the same process + file lock used by ordinary expiry refresh, so a
// late rejection cannot invalidate or refresh over a token another caller has
// already rotated.
//
// When the stored token no longer matches, the newer token is returned without
// calling the refresh endpoint. Refresh failures leave the stored credential in
// place; login/logout remain the only owners of credential deletion.
func (p *OAuthProvider) ForceRefreshRejectedToken(ctx context.Context, rejectedAccessToken string) (string, error) {
if p == nil || strings.TrimSpace(p.configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
rejectedAccessToken = strings.TrimSpace(rejectedAccessToken)
if rejectedAccessToken == "" {
return "", fmt.Errorf("rejected access token is empty")
}
if ctx == nil {
ctx = context.Background()
}
profile := strings.TrimSpace(RuntimeProfile())
key := newRejectedTokenRefreshKey(p.configDir, profile, rejectedAccessToken)
call, leader := beginRejectedTokenRefresh(key)
if !leader {
select {
case <-call.done:
return call.token, call.err
case <-ctx.Done():
return "", ctx.Err()
}
}
token, err, recordFailure := p.forceRefreshRejectedTokenOnce(ctx, profile, rejectedAccessToken, key)
finishRejectedTokenRefresh(key, call, token, err, recordFailure)
return token, err
}
func (p *OAuthProvider) forceRefreshRejectedTokenOnce(
ctx context.Context,
profile string,
rejectedAccessToken string,
key rejectedTokenRefreshKey,
) (string, error, bool) {
lock, err := oauthAcquireLock(ctx, p.configDir)
if err != nil {
return "", fmt.Errorf("acquiring dual lock: %w", err), false
}
defer lock.Release()
data, err := loadOAuthTokenUnderHeldLock(p.configDir, profile)
if err != nil {
return "", fmt.Errorf("reload rejected token: %w", err), false
}
current := strings.TrimSpace(data.AccessToken)
if current == "" {
clearRejectedTokenRefreshFailure(key)
return "", fmt.Errorf("stored access token is empty"), false
}
if current != rejectedAccessToken {
clearRejectedTokenRefreshFailure(key)
return current, nil, false
}
if cachedErr := recentRejectedTokenRefreshFailure(key); cachedErr != nil {
return "", cachedErr, false
}
if !data.IsRefreshTokenValid() {
return "", fmt.Errorf("refresh_token 已过期"), true
}
if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil {
return "", fmt.Errorf("本地登录态无法安全更新: %w", err), true
}
refreshed, err := oauthRefreshToken(p, ctx, data)
if err != nil {
return "", err, true
}
if refreshed == nil || strings.TrimSpace(refreshed.AccessToken) == "" {
return "", fmt.Errorf("force refresh returned empty access token"), true
}
return strings.TrimSpace(refreshed.AccessToken), nil, false
}
func newRejectedTokenRefreshKey(configDir, profile, rejectedAccessToken string) rejectedTokenRefreshKey {
canonicalDir := filepath.Clean(configDir)
if absolute, err := filepath.Abs(configDir); err == nil {
canonicalDir = filepath.Clean(absolute)
}
return rejectedTokenRefreshKey{
configDir: canonicalDir,
profile: strings.TrimSpace(profile),
tokenDigest: sha256.Sum256([]byte(strings.TrimSpace(rejectedAccessToken))),
}
}
func beginRejectedTokenRefresh(key rejectedTokenRefreshKey) (*rejectedTokenRefreshCall, bool) {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
if call := rejectedTokenRefreshCoordinator.inFlight[key]; call != nil {
call.participants++
return call, false
}
call := &rejectedTokenRefreshCall{done: make(chan struct{}), participants: 1}
rejectedTokenRefreshCoordinator.inFlight[key] = call
return call, true
}
func finishRejectedTokenRefresh(
key rejectedTokenRefreshKey,
call *rejectedTokenRefreshCall,
token string,
err error,
recordFailure bool,
) {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
call.token = token
call.err = err
delete(rejectedTokenRefreshCoordinator.inFlight, key)
if err == nil {
delete(rejectedTokenRefreshCoordinator.failures, key)
} else if recordFailure && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
now := time.Now()
if rejectedTokenRefreshCoordinator.now != nil {
now = rejectedTokenRefreshCoordinator.now()
}
rejectedTokenRefreshCoordinator.failures[key] = rejectedTokenRefreshFailure{at: now, err: err}
}
close(call.done)
}
func recentRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) error {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
now := time.Now()
if rejectedTokenRefreshCoordinator.now != nil {
now = rejectedTokenRefreshCoordinator.now()
}
for failureKey, failure := range rejectedTokenRefreshCoordinator.failures {
age := now.Sub(failure.at)
if age < 0 || age >= rejectedTokenRefreshFailureCooldown {
delete(rejectedTokenRefreshCoordinator.failures, failureKey)
}
}
if failure, ok := rejectedTokenRefreshCoordinator.failures[key]; ok {
return failure.err
}
return nil
}
func clearRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) {
rejectedTokenRefreshCoordinator.Lock()
delete(rejectedTokenRefreshCoordinator.failures, key)
rejectedTokenRefreshCoordinator.Unlock()
}
// loadOAuthTokenUnderHeldLock mirrors LoadTokenDataForProfile without taking a
// second, non-reentrant auth lock. Opaque edition storage hooks (for example
// Wukong's encrypted .data file) are read inside the caller's dual lock so the
// compare-and-refresh decision covers both Core and embedded storage.
func loadOAuthTokenUnderHeldLock(configDir, profile string) (*TokenData, error) {
hooks := edition.Get()
if hooks.LoadToken == nil {
data, err := oauthLoadTokenLocked(configDir, profile)
if err != nil {
return nil, err
}
if data == nil {
return nil, fmt.Errorf("stored token data is empty")
}
return data, nil
}
if strings.TrimSpace(profile) != "" {
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
}
blob, err := hooks.LoadToken(configDir)
if err != nil {
return nil, err
}
var data TokenData
if err := json.Unmarshal(blob, &data); err != nil {
return nil, fmt.Errorf("parsing token data from hook: %w", err)
}
return &data, nil
}
+4 -2
View File
@@ -47,8 +47,10 @@ func (m *Manager) GetToken() (string, string, error) {
}
return token, "file", nil
}
return "", "", fmt.Errorf("%s", i18n.T("未找到认证信息,请运行 dws auth login"))
if err != nil && !os.IsNotExist(err) {
return "", "", fmt.Errorf("load legacy token: %w", err)
}
return "", "", fmt.Errorf("%s: %w", i18n.T("未找到认证信息,请运行 dws auth login"), ErrTokenDataNotFound)
}
func (m *Manager) GetMCPURL() (string, error) {
+14 -5
View File
@@ -237,12 +237,21 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
data, readErr := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(data, 200))
// Preserve structured HTTP status semantics even when the response
// body is truncated. The body is diagnostic-only here, so read it
// best-effort and classify retryability from the status code.
if readErr != nil {
data = nil
}
return nil, &HTTPStatusError{
StatusCode: resp.StatusCode,
responseBody: truncateBody(data, 200),
}
}
if readErr != nil {
return nil, fmt.Errorf("reading response: %w", readErr)
}
return data, nil
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"errors"
"io"
"net/http"
"testing"
)
type postJSONRoundTripFunc func(*http.Request) (*http.Response, error)
func (f postJSONRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type oauthBrokenBody struct{}
func (oauthBrokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
func (oauthBrokenBody) Close() error { return nil }
func TestCrossPlatformCoveragePostJSONTruncatedErrorBodyKeepsHTTPStatus(t *testing.T) {
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
t.Run(http.StatusText(status), func(t *testing.T) {
provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: status, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil
})}}
_, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"})
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr.StatusCode != status {
t.Fatalf("postJSON() error = %v, want HTTPStatusError %d", err, status)
}
if errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("HTTP status error should not expose diagnostic body read failure: %v", err)
}
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got)
}
})
}
}
func TestCrossPlatformCoveragePostJSONOKTruncatedBodyIsTransient(t *testing.T) {
provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil
})}}
_, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"})
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("postJSON() error = %v, want io.ErrUnexpectedEOF", err)
}
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got)
}
}
+46 -12
View File
@@ -18,6 +18,7 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"log/slog"
"net"
@@ -115,6 +116,12 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Smart degradation: try silent refresh before opening browser.
if !force {
data, err := oauthLoadToken(p.configDir)
if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) {
if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr)
}
return nil, fmt.Errorf("load existing access token: %w", err)
}
if err == nil {
// Case 1: access_token still valid — no action needed.
if data.IsAccessTokenValid() {
@@ -295,7 +302,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
callbackTokenMu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprintf(w, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", exchangeErr.Error())
_, _ = fmt.Fprintf(w, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", html.EscapeString(oauthExchangeDisplayError(exchangeErr)))
select {
case resultCh <- callbackResult{err: exchangeErr}:
default:
@@ -636,36 +643,63 @@ continueLogin:
return tokenData, nil
}
// GetAccessToken returns a valid access token, auto-refreshing if needed.
// Uses a file lock with double-check pattern to prevent concurrent refresh
// from multiple CLI processes.
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
func oauthExchangeDisplayError(err error) string {
var statusErr *HTTPStatusError
if errors.As(err, &statusErr) && statusErr != nil {
return fmt.Sprintf("HTTP %d: token exchange failed", statusErr.StatusCode)
}
return err.Error()
}
// GetTokenSnapshot returns a valid token together with its expiry metadata.
// Storage and refresh failures retain their original cause; only a confirmed
// missing credential is reported as ErrTokenDataNotFound.
func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error) {
data, err := oauthLoadToken(p.configDir)
if err != nil {
return "", errors.New(i18n.T("未登录,请运行 dws auth login"))
if errors.Is(err, ErrTokenDataNotFound) || os.IsNotExist(err) {
return nil, fmt.Errorf("%s: %w", i18n.T("未登录,请运行 dws auth login"), ErrTokenDataNotFound)
}
return nil, fmt.Errorf("load access token: %w", err)
}
// Fast path: access_token still valid — no lock needed.
if data.IsAccessTokenValid() {
return data.AccessToken, nil
return data, nil
}
// Slow path: token expired — try locked refresh.
if data.IsRefreshTokenValid() {
refreshed, rErr := p.lockedRefresh(ctx)
if rErr == nil {
return refreshed.AccessToken, nil
return refreshed, nil
}
// A network, timeout, rate-limit or 5xx failure does not invalidate the
// refresh credential. Keep the profile active so a long-running source
// can retry after backoff. Terminal and unknown failures remain fatal.
if ClassifyRefreshFailure(rErr) != RefreshFailureTransient {
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
}
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
return "", fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
} else {
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
}
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
}
// GetAccessToken returns a valid access token, auto-refreshing if needed.
// Uses a file lock with double-check pattern to prevent concurrent refresh
// from multiple CLI processes.
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
data, err := p.GetTokenSnapshot(ctx)
if err != nil {
return "", err
}
return strings.TrimSpace(data.AccessToken), nil
}
// lockedRefresh attempts to refresh the token while holding dual-layer locks.
@@ -697,7 +731,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
// Double-check: re-load from disk — another goroutine/process may have refreshed
// while we were waiting for the lock.
data, err := oauthLoadTokenLocked(p.configDir, RuntimeProfile())
data, err := loadOAuthTokenUnderHeldLock(p.configDir, RuntimeProfile())
if err != nil {
return nil, err
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
)
// RefreshFailureClass separates refresh failures that may recover after a
// delay from failures that require new credentials or local intervention.
type RefreshFailureClass string
const (
RefreshFailureUnknown RefreshFailureClass = "unknown"
RefreshFailureTransient RefreshFailureClass = "transient"
RefreshFailureTerminal RefreshFailureClass = "terminal"
)
// HTTPStatusError preserves an OAuth endpoint status for structured retry
// decisions without copying an untrusted response body into logs.
type HTTPStatusError struct {
StatusCode int
responseBody string
}
func (e *HTTPStatusError) Error() string {
if e == nil {
return "OAuth endpoint request failed"
}
return fmt.Sprintf("HTTP %d", e.StatusCode)
}
func httpStatusResponseBody(err error) string {
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr == nil {
return ""
}
return statusErr.responseBody
}
// ClassifyRefreshFailure uses only structured transport and HTTP signals.
// Unknown errors, including parse, keychain and persistence failures, remain
// fatal so a long-running source cannot retry an error that needs user action.
func ClassifyRefreshFailure(err error) RefreshFailureClass {
if err == nil {
return RefreshFailureUnknown
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return RefreshFailureTransient
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return RefreshFailureTransient
}
var netErr net.Error
if errors.As(err, &netErr) {
return RefreshFailureTransient
}
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr == nil {
return RefreshFailureUnknown
}
if statusErr.StatusCode == http.StatusRequestTimeout ||
statusErr.StatusCode == http.StatusTooManyRequests ||
statusErr.StatusCode >= http.StatusInternalServerError {
return RefreshFailureTransient
}
if statusErr.StatusCode == http.StatusBadRequest ||
statusErr.StatusCode == http.StatusUnauthorized ||
statusErr.StatusCode == http.StatusForbidden {
return RefreshFailureTerminal
}
return RefreshFailureUnknown
}
+155
View File
@@ -0,0 +1,155 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package auth
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageClassifyRefreshFailureUsesStructuredSignals(t *testing.T) {
tests := []struct {
name string
err error
want RefreshFailureClass
}{
{name: "deadline", err: context.DeadlineExceeded, want: RefreshFailureTransient},
{name: "network", err: &url.Error{Op: "Post", URL: "https://oauth.test", Err: context.DeadlineExceeded}, want: RefreshFailureTransient},
{name: "request timeout", err: &HTTPStatusError{StatusCode: http.StatusRequestTimeout}, want: RefreshFailureTransient},
{name: "rate limited", err: &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, want: RefreshFailureTransient},
{name: "server unavailable", err: &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}, want: RefreshFailureTransient},
{name: "refresh rejected", err: &HTTPStatusError{StatusCode: http.StatusUnauthorized}, want: RefreshFailureTerminal},
{name: "invalid grant", err: &HTTPStatusError{StatusCode: http.StatusBadRequest}, want: RefreshFailureTerminal},
{name: "forbidden", err: &HTTPStatusError{StatusCode: http.StatusForbidden}, want: RefreshFailureTerminal},
{name: "local persistence", err: errors.New("save refreshed token failed"), want: RefreshFailureUnknown},
{name: "nil error", err: nil, want: RefreshFailureUnknown},
{name: "dns failure", err: &net.DNSError{Err: "no such host", Name: "oauth.test"}, want: RefreshFailureTransient},
{name: "redirect status", err: &HTTPStatusError{StatusCode: http.StatusFound}, want: RefreshFailureUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyRefreshFailure(tt.err); got != tt.want {
t.Fatalf("ClassifyRefreshFailure() = %q, want %q", got, tt.want)
}
})
}
}
func TestCrossPlatformCoverageHTTPStatusErrorRetainsStatusThroughWrapping(t *testing.T) {
want := &HTTPStatusError{StatusCode: http.StatusTooManyRequests}
err := errors.Join(errors.New("refresh failed"), want)
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
t.Fatalf("ClassifyRefreshFailure() = %q, want transient", got)
}
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusTooManyRequests {
t.Fatalf("HTTP status error not retained: %v", err)
}
if got, want := statusErr.Error(), "HTTP 429"; got != want {
t.Fatalf("HTTP status error = %q, want %q", got, want)
}
var nilStatus *HTTPStatusError
if got, want := nilStatus.Error(), "OAuth endpoint request failed"; got != want {
t.Fatalf("nil HTTP status error = %q, want %q", got, want)
}
}
func TestCrossPlatformCoverageOAuthExchangeDisplayErrorFallsBackToPlainError(t *testing.T) {
if got, want := oauthExchangeDisplayError(&HTTPStatusError{StatusCode: http.StatusBadGateway}), "HTTP 502: token exchange failed"; got != want {
t.Fatalf("status display error = %q, want %q", got, want)
}
if got, want := oauthExchangeDisplayError(errors.New("exchange failed")), "exchange failed"; got != want {
t.Fatalf("plain display error = %q, want %q", got, want)
}
}
func TestCrossPlatformCoveragePostJSONClassifiesStatusWithoutLoggingResponseBody(t *testing.T) {
const secretBody = `{"refreshToken":"must-not-reach-logs"}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(secretBody))
}))
defer server.Close()
provider := &OAuthProvider{httpClient: server.Client()}
_, err := provider.postJSON(context.Background(), server.URL, map[string]string{"grantType": "refresh_token"})
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
t.Fatalf("ClassifyRefreshFailure() = %q, want transient: %v", got, err)
}
if strings.Contains(err.Error(), "must-not-reach-logs") {
t.Fatalf("postJSON error leaked response body: %v", err)
}
if got := httpStatusResponseBody(err); !strings.Contains(got, "must-not-reach-logs") {
t.Fatalf("postJSON did not retain bounded response details for internal classification: %q", got)
}
}
func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientRefreshFailures(t *testing.T) {
oldLoad := oauthLoadToken
oldLoadLocked := oauthLoadTokenLocked
oldAcquire := oauthAcquireLock
oldRefresh := oauthRefreshToken
oldMark := oauthMarkProfile
oldEdition := edition.Get()
t.Cleanup(func() {
oauthLoadToken = oldLoad
oauthLoadTokenLocked = oldLoadLocked
oauthAcquireLock = oldAcquire
oauthRefreshToken = oldRefresh
oauthMarkProfile = oldMark
edition.Override(oldEdition)
})
edition.Override(&edition.Hooks{})
expired := &TokenData{
AccessToken: "expired-access",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshToken: "refresh",
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp",
UserID: "user",
}
oauthLoadToken = func(string) (*TokenData, error) { return expired, nil }
oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return expired, nil }
oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }
markCalls := 0
oauthMarkProfile = func(_, _, status string) error {
if status != ProfileStatusExpired {
t.Fatalf("profile status = %q, want %q", status, ProfileStatusExpired)
}
markCalls++
return nil
}
provider := NewOAuthProvider(t.TempDir(), nil)
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}
}
if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTransient {
t.Fatalf("transient refresh error = %v", err)
}
if markCalls != 0 {
t.Fatalf("transient refresh marked profile expired %d times", markCalls)
}
oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &HTTPStatusError{StatusCode: http.StatusUnauthorized}
}
if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTerminal {
t.Fatalf("terminal refresh error = %v", err)
}
if markCalls != 1 {
t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls)
}
}
@@ -0,0 +1,510 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type rejectedTokenHookStore struct {
mu sync.Mutex
data TokenData
deletes int
}
func (s *rejectedTokenHookStore) load(string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return json.Marshal(s.data)
}
func (s *rejectedTokenHookStore) save(_ string, blob []byte) error {
var data TokenData
if err := json.Unmarshal(blob, &data); err != nil {
return err
}
s.mu.Lock()
s.data = data
s.mu.Unlock()
return nil
}
func (s *rejectedTokenHookStore) delete(string) error {
s.mu.Lock()
s.data = TokenData{}
s.deletes++
s.mu.Unlock()
return nil
}
func (s *rejectedTokenHookStore) snapshot() (TokenData, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.data, s.deletes
}
func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenHookStore {
t.Helper()
store := &rejectedTokenHookStore{data: data}
previousHooks := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: store.load,
SaveToken: store.save,
DeleteToken: store.delete,
})
t.Cleanup(func() { edition.Override(previousHooks) })
return store
}
func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) {
t.Helper()
resetRejectedTokenRefreshCoordinator(t)
previous := oauthRefreshToken
oauthRefreshToken = fn
t.Cleanup(func() { oauthRefreshToken = previous })
}
func resetRejectedTokenRefreshCoordinator(t *testing.T) {
t.Helper()
reset := func() {
rejectedTokenRefreshCoordinator.Lock()
rejectedTokenRefreshCoordinator.inFlight = make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall)
rejectedTokenRefreshCoordinator.failures = make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure)
rejectedTokenRefreshCoordinator.now = time.Now
rejectedTokenRefreshCoordinator.Unlock()
}
reset()
t.Cleanup(reset)
}
func waitForRejectedTokenRefreshParticipants(t *testing.T, key rejectedTokenRefreshKey, want int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
rejectedTokenRefreshCoordinator.Lock()
call := rejectedTokenRefreshCoordinator.inFlight[key]
got := 0
if call != nil {
got = call.participants
}
rejectedTokenRefreshCoordinator.Unlock()
if got >= want {
return
}
if time.Now().After(deadline) {
t.Fatalf("refresh participants = %d, want %d", got, want)
}
time.Sleep(time.Millisecond)
}
}
func installProfilesAcquireProbe(t *testing.T) <-chan struct{} {
t.Helper()
previous := profilesAcquireDualLock
attempted := make(chan struct{}, 1)
profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) {
attempted <- struct{}{}
return previous(ctx, configDir)
}
t.Cleanup(func() { profilesAcquireDualLock = previous })
return attempted
}
func waitForProfilesAcquire(t *testing.T, attempted <-chan struct{}) {
t.Helper()
select {
case <-attempted:
case <-time.After(2 * time.Second):
t.Fatal("public opaque token mutation did not enter the Core dual lock")
}
}
func validRejectedTokenData(accessToken string) TokenData {
return TokenData{
AccessToken: accessToken,
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
Source: "mcp",
ClientID: "client-id",
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenConcurrentCallersExchangeOnce(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
var refreshCalls atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
if refreshCalls.Add(1) == 1 {
close(started)
}
<-release
updated := *data
updated.AccessToken = "new-access"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
provider := NewOAuthProvider(t.TempDir(), nil)
const workers = 8
results := make(chan string, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
results <- token
errs <- err
}()
}
<-started
close(release)
wg.Wait()
close(results)
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for token := range results {
if token != "new-access" {
t.Fatalf("token = %q, want new-access", token)
}
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls = %d, want 1", got)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "new-access" || deletes != 0 {
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailurePreservesCredential(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
refreshErr := errors.New("temporary refresh failure")
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, refreshErr
})
_, err := NewOAuthProvider(t.TempDir(), nil).ForceRefreshRejectedToken(context.Background(), "old-access")
if !errors.Is(err, refreshErr) {
t.Fatalf("error = %v, want refresh cause", err)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
t.Fatalf("credential changed after transient failure: %#v, deletes=%d", stored, deletes)
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailureIsSingleflightAndCooledDown(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
previousProfile := RuntimeProfile()
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
refreshErr := errors.New("temporary refresh failure")
var refreshCalls atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
baseNow := time.Now()
var nowNanos atomic.Int64
nowNanos.Store(baseNow.UnixNano())
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
call := refreshCalls.Add(1)
if call == 1 {
close(started)
<-release
return nil, refreshErr
}
updated := *data
updated.AccessToken = "recovered-access"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
rejectedTokenRefreshCoordinator.Lock()
rejectedTokenRefreshCoordinator.now = func() time.Time {
return time.Unix(0, nowNanos.Load())
}
rejectedTokenRefreshCoordinator.Unlock()
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
const workers = 8
start := make(chan struct{})
ready := make(chan struct{}, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
ready <- struct{}{}
<-start
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
errs <- err
}()
}
for range workers {
<-ready
}
close(start)
<-started
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
waitForRejectedTokenRefreshParticipants(t, key, workers)
close(release)
wg.Wait()
close(errs)
for err := range errs {
if !errors.Is(err, refreshErr) {
t.Fatalf("shared refresh error = %v, want %v", err, refreshErr)
}
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls after concurrent failure = %d, want 1", got)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
t.Fatalf("credential changed after shared failure: %#v, deletes=%d", stored, deletes)
}
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
t.Fatalf("cooldown error = %v, want %v", err, refreshErr)
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls inside cooldown = %d, want 1", got)
}
nowNanos.Store(baseNow.Add(rejectedTokenRefreshFailureCooldown + time.Nanosecond).UnixNano())
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
if err != nil || token != "recovered-access" {
t.Fatalf("refresh after cooldown = %q, %v", token, err)
}
if got := refreshCalls.Load(); got != 2 {
t.Fatalf("refresh calls after cooldown = %d, want 2", got)
}
stored, deletes = store.snapshot()
if stored.AccessToken != "recovered-access" || deletes != 0 {
t.Fatalf("stored token after cooldown recovery = %q, deletes=%d", stored.AccessToken, deletes)
}
}
func TestCrossPlatformCoverageForceRefreshRejectedTokenChangedDuringCooldownUsesNewToken(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
previousProfile := RuntimeProfile()
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
refreshErr := errors.New("temporary refresh failure")
var refreshCalls atomic.Int32
baseNow := time.Now()
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
refreshCalls.Add(1)
return nil, refreshErr
})
rejectedTokenRefreshCoordinator.Lock()
rejectedTokenRefreshCoordinator.now = func() time.Time { return baseNow }
rejectedTokenRefreshCoordinator.Unlock()
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
t.Fatalf("initial refresh error = %v, want %v", err, refreshErr)
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("initial refresh calls = %d, want 1", got)
}
store.mu.Lock()
store.data = validRejectedTokenData("externally-refreshed")
store.mu.Unlock()
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
if err != nil || token != "externally-refreshed" {
t.Fatalf("refresh after external publication = %q, %v", token, err)
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("external publication triggered another exchange: calls=%d", got)
}
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
rejectedTokenRefreshCoordinator.Lock()
_, failurePresent := rejectedTokenRefreshCoordinator.failures[key]
rejectedTokenRefreshCoordinator.Unlock()
if failurePresent {
t.Fatal("old-token failure cache was not cleared after external publication")
}
}
func TestCrossPlatformCoverageOpaquePublisherWaitsForRejectedTokenRefresh(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
close(started)
<-release
updated := *data
updated.AccessToken = "refreshed-from-old"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
refreshResult := make(chan struct {
token string
err error
}, 1)
go func() {
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
refreshResult <- struct {
token string
err error
}{token: token, err: err}
}()
<-started
acquireAttempted := installProfilesAcquireProbe(t)
publishResult := make(chan error, 1)
go func() {
publishResult <- SaveTokenData(configDir, ptrTokenData(validRejectedTokenData("login-published")))
}()
waitForProfilesAcquire(t, acquireAttempted)
releaseOnce.Do(func() { close(release) })
refresh := <-refreshResult
if refresh.err != nil || refresh.token != "refreshed-from-old" {
t.Fatalf("refresh result = %q, %v", refresh.token, refresh.err)
}
if err := <-publishResult; err != nil {
t.Fatalf("publish token: %v", err)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "login-published" || deletes != 0 {
t.Fatalf("older refresh overwrote login publication: token=%q deletes=%d", stored.AccessToken, deletes)
}
}
func TestCrossPlatformCoverageOpaqueLogoutWaitsForRejectedTokenRefresh(t *testing.T) {
for _, tc := range []struct {
name string
logout func(string) error
}{
{name: "current profile", logout: func(configDir string) error {
return DeleteTokenDataForProfile(configDir, "")
}},
{name: "all profiles", logout: DeleteAllTokenData},
} {
t.Run(tc.name, func(t *testing.T) {
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
close(started)
<-release
updated := *data
updated.AccessToken = "refreshed-before-logout"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
configDir := t.TempDir()
provider := NewOAuthProvider(configDir, nil)
refreshResult := make(chan error, 1)
go func() {
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
refreshResult <- err
}()
<-started
acquireAttempted := installProfilesAcquireProbe(t)
logoutResult := make(chan error, 1)
go func() { logoutResult <- tc.logout(configDir) }()
waitForProfilesAcquire(t, acquireAttempted)
releaseOnce.Do(func() { close(release) })
if err := <-refreshResult; err != nil {
t.Fatalf("refresh: %v", err)
}
if err := <-logoutResult; err != nil {
t.Fatalf("logout: %v", err)
}
stored, deletes := store.snapshot()
if stored.AccessToken != "" || stored.RefreshToken != "" || deletes != 1 {
t.Fatalf("refresh resurrected logged-out credential: %#v deletes=%d", stored, deletes)
}
})
}
}
func ptrTokenData(data TokenData) *TokenData {
return &data
}
func TestCrossPlatformCoverageOAuthLockedRefreshReadsOpaqueEditionStore(t *testing.T) {
data := validRejectedTokenData("expired-access")
data.ExpiresAt = time.Now().Add(-time.Hour)
store := installRejectedTokenHookStore(t, data)
var refreshCalls atomic.Int32
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, current *TokenData) (*TokenData, error) {
refreshCalls.Add(1)
updated := *current
updated.AccessToken = "proactively-refreshed"
updated.ExpiresAt = time.Now().Add(time.Hour)
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
return nil, err
}
return &updated, nil
})
token, err := NewOAuthProvider(t.TempDir(), nil).GetAccessToken(context.Background())
if err != nil || token != "proactively-refreshed" {
t.Fatalf("GetAccessToken() = %q, %v", token, err)
}
if refreshCalls.Load() != 1 {
t.Fatalf("refresh calls = %d, want 1", refreshCalls.Load())
}
stored, deletes := store.snapshot()
if stored.AccessToken != "proactively-refreshed" || deletes != 0 {
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
}
}
+11
View File
@@ -14,6 +14,7 @@
package auth
import (
"fmt"
"os"
"testing"
@@ -34,7 +35,17 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.TestNamespaceEnv + ": " + err.Error())
}
code := m.Run()
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
fmt.Fprintf(os.Stderr, "internal/auth keychain cleanup: %v\n", err)
if code == 0 {
code = 1
}
}
_ = os.RemoveAll(tmpDir)
os.Exit(code)
}
+37 -11
View File
@@ -127,6 +127,10 @@ const tokenJSONFile = "token.json"
type TokenMarker struct {
UpdatedAt string `json:"updated_at"`
ManualToken bool `json:"manual_token,omitempty"`
// Revision changes on every credential publication. Runtime token caches
// use it as a cheap cross-process invalidation signal without reading the
// platform keychain on every request.
Revision string `json:"revision,omitempty"`
}
// WriteTokenMarker writes a token.json marker containing only an updated_at
@@ -147,6 +151,7 @@ func writeTokenMarker(configDir string, manual bool) error {
marker := TokenMarker{
UpdatedAt: time.Now().Format(time.RFC3339),
ManualToken: manual,
Revision: uuid.NewString(),
}
data, _ := tokenJSONMarshalIndent(marker, "", " ")
if err := tokenMkdirAll(configDir, 0o700); err != nil {
@@ -159,6 +164,27 @@ func writeTokenMarker(configDir string, manual bool) error {
return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
}
// ReadTokenMarkerRevision returns the current credential publication revision.
// Existing markers without a revision remain readable, but callers must avoid
// caching them because they cannot prove that the credential is unchanged.
func ReadTokenMarkerRevision(configDir string) (revision string, present bool, err error) {
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
}
return "", false, fmt.Errorf("read token marker: %w", err)
}
var marker TokenMarker
if err := json.Unmarshal(data, &marker); err != nil {
// The marker is only a cache-coherency hint. A malformed historical or
// externally modified marker must disable caching, not make an otherwise
// valid credential unusable.
return "", true, nil
}
return strings.TrimSpace(marker.Revision), true, nil
}
func manualTokenMarkerActive(configDir string) (bool, error) {
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
if err != nil {
@@ -184,13 +210,10 @@ func DeleteTokenMarker(configDir string) error {
return nil
}
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
// registered, it delegates entirely to the hook; otherwise it falls back
// to the default keychain-based storage.
// SaveTokenData persists TokenData under the auth dual lock. When an edition
// hook (SaveToken) is registered, the locked write delegates to that hook;
// otherwise it falls back to the default keychain-based storage.
func SaveTokenData(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return saveTokenViaHook(h, configDir, data)
}
return withProfilesLock(configDir, func() error {
return saveTokenDataLocked(configDir, data)
})
@@ -464,9 +487,8 @@ func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
return orgData, nil
}
// DeleteTokenData removes token data. When an edition hook (DeleteToken) is
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain + legacy cleanup.
// DeleteTokenData removes token data. Edition hooks and the default keychain
// path are both serialized with refresh through the auth dual lock.
func DeleteTokenData(configDir string) error {
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
}
@@ -478,7 +500,9 @@ func DeleteTokenDataForProfile(configDir, profile string) error {
if strings.TrimSpace(profile) != "" {
return fmt.Errorf("profile selection is not supported by the current auth backend")
}
return h.DeleteToken(configDir)
return withProfilesLock(configDir, func() error {
return h.DeleteToken(configDir)
})
}
return withProfilesLock(configDir, func() error {
return deleteTokenDataForProfileLocked(configDir, profile)
@@ -913,7 +937,9 @@ func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error {
// DeleteAllTokenData removes all profile-scoped and legacy token data.
func DeleteAllTokenData(configDir string) error {
if h := edition.Get(); h.DeleteToken != nil {
return h.DeleteToken(configDir)
return withProfilesLock(configDir, func() error {
return h.DeleteToken(configDir)
})
}
return withProfilesLock(configDir, func() error {
var firstErr error
+11
View File
@@ -304,3 +304,14 @@ func splitSchemaPathTokens(raw string) []string {
}
return out
}
// normalizeSchemaQueryCLIPath accepts the historical query spellings while
// keeping authored Registry CLI paths strict and space-separated. Canonical
// identity lookup still runs before this compatibility normalization.
func normalizeSchemaQueryCLIPath(path string) string {
parts := splitSchemaPathTokens(strings.TrimSpace(path))
if len(parts) > 0 && parts[0] == "dws" {
parts = parts[1:]
}
return strings.Join(parts, " ")
}
+1 -1
View File
@@ -290,7 +290,7 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (
return payload, nil
}
raw := strings.TrimSpace(args[0])
if tool, ok := loaded.Index.Resolve(raw); ok {
if tool, ok := loaded.Index.ResolveQuery(raw); ok {
return schemaToolForResolvedPath(tool, raw).ToPayload()
}
tokens := splitSchemaPathTokens(raw)
@@ -194,6 +194,23 @@ func TestCrossPlatformCoverageSchemaCatalogLookupAndConversionEdges(t *testing.T
}
}
func TestEmbeddedSchemaLookupAcceptsCompatibleCLIPathSeparators(t *testing.T) {
loaded := embeddedSchemaCatalog()
for _, path := range []string{
"dev app list",
"dev.app.list",
"dev/app/list",
} {
payload, err := schemaPayloadFromLoadedCatalog(loaded, []string{path})
if err != nil {
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) error = %v", path, err)
}
if got := schemaString(payload["canonical_path"]); got != "dev.list_dev_app" {
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) canonical_path = %q, want %q", path, got, "dev.list_dev_app")
}
}
}
type catalogHookSnapshot struct {
parameterBindings func(BoundCommandRegistry, SchemaRegistry) error
dryRun func(SchemaRegistry) error
+16
View File
@@ -617,6 +617,22 @@ func (i SchemaIndex) Resolve(path string) (ToolSpec, bool) {
return i.registry.Products[location.product].Tools[location.tool], true
}
// ResolveQuery adds compatibility for dotted and slash-separated CLI paths at
// the user-facing query boundary. Resolve remains strict because Registry
// validation uses it to detect missing canonical identities without falling
// through to a similarly spelled CLI path.
func (i SchemaIndex) ResolveQuery(path string) (ToolSpec, bool) {
if tool, ok := i.Resolve(path); ok {
return tool, true
}
canonical, ok := i.byCLIPath[normalizeSchemaQueryCLIPath(path)]
if !ok {
return ToolSpec{}, false
}
location := i.byCanonical[canonical]
return i.registry.Products[location.product].Tools[location.tool], true
}
// CanonicalPaths returns the complete tool identity set in stable order.
func (i SchemaIndex) CanonicalPaths() []string {
paths := make([]string, 0, len(i.byCanonical))
@@ -514,6 +514,19 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) {
t.Fatalf("Resolve(%q) = %#v, %v", path, resolved.Identity, ok)
}
}
for _, path := range []string{
"calendar.attendee.delete",
"calendar/attendee/delete",
"dws.calendar.attendee.delete",
} {
resolved, ok := index.ResolveQuery(path)
if !ok || resolved.Identity.CanonicalPath != "calendar.attendee_delete" {
t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok)
}
}
if _, ok := index.ResolveQuery("calendar.attendee.unknown"); ok {
t.Fatal("unknown dotted CLI path unexpectedly resolved")
}
if got := index.CanonicalPaths(); !reflect.DeepEqual(got, []string{"calendar.attendee_delete"}) {
t.Fatalf("CanonicalPaths() = %#v", got)
}
@@ -154,6 +154,35 @@ func TestSelectionExplicitEmptyListSurvivesFinalDelivery(t *testing.T) {
}
}
func TestCompatibleSchemaAliasSeparatorsSurviveFinalDelivery(t *testing.T) {
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{
Canonical: "sample.run",
CLIPath: "sample category run",
Aliases: []string{"sample legacy execute"},
})
encoded, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
loaded, err := decodeSchemaCatalogSnapshot(encoded)
if err != nil {
t.Fatalf("decodeSchemaCatalogSnapshot(): %v", err)
}
canonical, err := schemaPayloadFromLoadedCatalog(loaded, []string{"sample.run"})
if err != nil {
t.Fatalf("canonical query: %v", err)
}
for _, path := range []string{"sample legacy execute", "sample.legacy.execute", "sample/legacy/execute"} {
alias, aliasErr := schemaPayloadFromLoadedCatalog(loaded, []string{path})
if aliasErr != nil {
t.Fatalf("alias query %q: %v", path, aliasErr)
}
if problem := schemaAliasViewProblem(canonical, alias, "sample legacy execute"); problem != "" {
t.Fatalf("alias projection for %q: %s", path, problem)
}
}
}
func TestValidateSchemaDeliveryInvariantsAllowsOnlyEnvelopeHashes(t *testing.T) {
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{Canonical: "sample.run", CLIPath: "sample run"})
snapshot.SurfaceHash = "sha256:reviewed-command-registry"
@@ -16,7 +16,7 @@ import (
// Go's normal per-package coverage accounting attributes the exercised Schema
// assembly code to internal/cli.
func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
resolved, err := cli.ResolveSchemaBuild(root)
if err != nil {
t.Fatalf("ResolveSchemaBuild() error = %v", err)
@@ -33,17 +33,17 @@ func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
if len(snapshot.Tools) == 0 {
t.Fatal("production Schema snapshot contains no tools")
}
registry, err := cli.AssembleSchemaRegistry(app.NewRootCommand())
registry, err := cli.AssembleSchemaRegistry(app.NewSchemaSourceRootCommand())
if err != nil {
t.Fatalf("AssembleSchemaRegistry() error = %v", err)
}
if len(registry.Products) == 0 {
t.Fatal("assembled production Schema registry contains no products")
}
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewRootCommand()); err != nil {
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil {
t.Fatalf("ValidateEmbeddedRuntimeSchemaCompleteness() error = %v", err)
}
root = app.NewRootCommand()
root = app.NewSchemaSourceRootCommand()
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -284,7 +284,7 @@ func runtimeSchemaPayloadFromRegistry(registry SchemaRegistry, args []string) (m
}
raw := strings.TrimSpace(args[0])
if tool, ok := index.Resolve(raw); ok {
if tool, ok := index.ResolveQuery(raw); ok {
tool = schemaToolForResolvedPath(tool, raw)
return renderRegistryToolPayload(tool)
}
@@ -338,7 +338,7 @@ func runtimeSchemaAllPayloadFromRegistry(registry SchemaRegistry) (map[string]an
}
func schemaToolForResolvedPath(tool ToolSpec, raw string) ToolSpec {
normalized := normalizeSchemaCLIPath(raw)
normalized := normalizeSchemaQueryCLIPath(raw)
if normalized == "" || normalized == tool.Identity.CLIPath || normalized == tool.Identity.PrimaryCLIPath {
return tool
}
+61 -17
View File
@@ -31,6 +31,31 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
type consumeReadyWriter struct {
ready chan struct{}
once sync.Once
}
func newConsumeReadyWriter() *consumeReadyWriter {
return &consumeReadyWriter{ready: make(chan struct{})}
}
func (w *consumeReadyWriter) Write(p []byte) (int, error) {
if bytes.Contains(p, []byte("[event] ready ")) {
w.once.Do(func() { close(w.ready) })
}
return len(p), nil
}
func waitForConsumerReady(t *testing.T, ready <-chan struct{}) {
t.Helper()
select {
case <-ready:
case <-time.After(3 * time.Second):
t.Fatal("consumer did not report ready")
}
}
// TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time
// event_types pushdown contract (plan §4 unsung superpower): a consumer
// subscribing to "im.*" must NOT receive "approval.*" events even when
@@ -49,40 +74,50 @@ func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) {
defer func() { cancel(); <-runDone }()
var imBuf, approvalBuf bytes.Buffer
var wg sync.WaitGroup
wg.Add(2)
imReady := newConsumeReadyWriter()
approvalReady := newConsumeReadyWriter()
consumeCtx, cancelConsumers := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelConsumers()
consumeDone := make(chan error, 2)
// Consumer A: im.* only
go func() {
defer wg.Done()
_ = Run(context.Background(), Config{
consumeDone <- Run(consumeCtx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &imBuf,
Stderr: io.Discard,
Stderr: imReady,
EventTypes: []string{"im.*"},
MaxEvents: 3, // 3 im events expected
})
}()
// Consumer B: approval.* only
go func() {
defer wg.Done()
_ = Run(context.Background(), Config{
consumeDone <- Run(consumeCtx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &approvalBuf,
Stderr: io.Discard,
Stderr: approvalReady,
EventTypes: []string{"approval.*"},
MaxEvents: 2, // 2 approval events expected
})
}()
// Give both consumers time to Hello + register.
time.Sleep(200 * time.Millisecond)
waitForConsumerReady(t, imReady.ready)
waitForConsumerReady(t, approvalReady.ready)
close(trigger)
wg.Wait()
for range 2 {
select {
case err := <-consumeDone:
if err != nil {
t.Fatalf("consume failed: %v", err)
}
case <-consumeCtx.Done():
t.Fatalf("consumers did not finish: %v", consumeCtx.Err())
}
}
// Verify consumer A got exactly the 3 im.* events.
imLines := nonEmptyLines(imBuf.String())
@@ -130,23 +165,32 @@ func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) {
defer func() { cancel(); <-runDone }()
var buf bytes.Buffer
consumeDone := make(chan struct{})
ready := newConsumeReadyWriter()
consumeCtx, cancelConsumer := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelConsumer()
consumeDone := make(chan error, 1)
go func() {
defer close(consumeDone)
_ = Run(context.Background(), Config{
consumeDone <- Run(consumeCtx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &buf,
Stderr: io.Discard,
Stderr: ready,
EventTypes: []string{"im.*"},
Filter: `\.at_v1$`, // only at_v1 events
MaxEvents: 1,
})
}()
time.Sleep(150 * time.Millisecond)
waitForConsumerReady(t, ready.ready)
close(trigger)
<-consumeDone
select {
case err := <-consumeDone:
if err != nil {
t.Fatalf("consume failed: %v", err)
}
case <-consumeCtx.Done():
t.Fatalf("consumer did not finish: %v", consumeCtx.Err())
}
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
+28 -9
View File
@@ -63,9 +63,10 @@ func (i Identity) Key() string {
}
type Client struct {
BaseURL string
HTTPClient *http.Client
Identity Identity
BaseURL string
HTTPClient *http.Client
Identity Identity
AccessTokenProvider func(context.Context) (string, error)
}
type CreateSubscriptionRequest struct {
@@ -337,8 +338,9 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
if c == nil {
return errors.New("personal event: nil client")
}
if c.Identity.AccessToken == "" {
return errors.New("personal event: access token is required")
accessToken, err := c.resolveAccessToken(ctx)
if err != nil {
return err
}
u := strings.TrimRight(c.BaseURL, "/") + path
if len(q) > 0 {
@@ -358,7 +360,7 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
if err != nil {
return fmt.Errorf("personal event: create request: %w", err)
}
c.decorate(req)
c.decorate(req, accessToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
@@ -424,9 +426,26 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
return json.Unmarshal(data, out)
}
func (c *Client) decorate(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken)
req.Header.Set("x-user-access-token", c.Identity.AccessToken)
func (c *Client) resolveAccessToken(ctx context.Context) (string, error) {
if c.AccessTokenProvider != nil {
token, err := c.AccessTokenProvider(ctx)
if err != nil {
return "", fmt.Errorf("personal event: resolve access token: %w", err)
}
if token = strings.TrimSpace(token); token != "" {
return token, nil
}
return "", errors.New("personal event: access token provider returned empty token")
}
if token := strings.TrimSpace(c.Identity.AccessToken); token != "" {
return token, nil
}
return "", errors.New("personal event: access token is required")
}
func (c *Client) decorate(req *http.Request, accessToken string) {
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-user-access-token", accessToken)
req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID)
req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID)
if c.Identity.CorpID != "" {
@@ -0,0 +1,56 @@
package personal
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
)
type accessTokenRoundTripper func(*http.Request) (*http.Response, error)
func (f accessTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestCrossPlatformCoverageClientResolvesAccessTokenPerRequest(t *testing.T) {
tokens := []string{"token-a", "token-b"}
calls := 0
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
client.AccessTokenProvider = func(context.Context) (string, error) {
token := tokens[calls]
calls++
return token, nil
}
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(req *http.Request) (*http.Response, error) {
want := tokens[calls-1]
if got := req.Header.Get("Authorization"); got != "Bearer "+want {
t.Fatalf("Authorization = %q, want Bearer %s", got, want)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[]}}`)), Header: make(http.Header)}, nil
})}
for range 2 {
if _, err := client.ListSubscriptions(context.Background(), ListOptions{}); err != nil {
t.Fatal(err)
}
}
if calls != 2 {
t.Fatalf("provider calls = %d, want 2", calls)
}
}
func TestCrossPlatformCoverageClientDoesNotFallBackAfterProviderFailure(t *testing.T) {
want := errors.New("keychain failed")
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
client.AccessTokenProvider = func(context.Context) (string, error) { return "", want }
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(*http.Request) (*http.Response, error) {
t.Fatal("HTTP must not run after token provider failure")
return nil, nil
})}
_, err := client.ListSubscriptions(context.Background(), ListOptions{})
if !errors.Is(err, want) {
t.Fatalf("error = %v, want %v", err, want)
}
}
+2 -2
View File
@@ -414,7 +414,7 @@ func TestCrossPlatformCoveragePortalStartEndToEndAndFailures(t *testing.T) {
return s
}
network := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errSourceInjected })}
s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network})
s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network, DisableReconnect: true})
if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil {
t.Fatal("ticket failure expected")
}
@@ -483,7 +483,7 @@ func TestCrossPlatformCoveragePortalStartHandshakeReadAndAckErrors(t *testing.T)
makeSource := func(endpoint string) *DingtalkSource {
s, err := New(Config{PortalTicket: &PortalTicketConfig{
TicketURL: "https://ticket", AccessToken: "token", SourceID: "source",
HTTPClient: staticPersonalTicketClient(endpoint, ""),
HTTPClient: staticPersonalTicketClient(endpoint, ""), DisableReconnect: true,
}})
if err != nil {
t.Fatal(err)
+117 -26
View File
@@ -27,6 +27,7 @@ import (
"sync/atomic"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/gorilla/websocket"
@@ -41,19 +42,30 @@ const (
)
type PersonalConfig struct {
AccessToken string
ClientID string
ClientSecret string
SourceID string
TicketURL string
TicketMode string
HTTPClient *http.Client
WebSocketDialer *websocket.Dialer
Now func() time.Time
ReconnectMin time.Duration
ReconnectMax time.Duration
AccessToken string
AccessTokenProvider AccessTokenProvider
ForceRefreshToken ForceRefreshTokenFn
ClientID string
ClientSecret string
SourceID string
TicketURL string
TicketMode string
HTTPClient *http.Client
WebSocketDialer *websocket.Dialer
Now func() time.Time
ReconnectMin time.Duration
ReconnectMax time.Duration
}
type AccessTokenProvider func(context.Context) (string, error)
// ForceRefreshTokenFn rotates an access token that the server has just
// rejected (HTTP 401). It receives the exact rejected token so the caller's
// compare-and-refresh logic can skip the refresh when another goroutine has
// already rotated it, and returns the fresh token to retry with. Optional:
// when nil a 401 stays fatal, matching the previous behavior.
type ForceRefreshTokenFn func(ctx context.Context, rejectedToken string) (string, error)
type PersonalSource struct {
cfg PersonalConfig
machine *Machine
@@ -73,8 +85,8 @@ type ticketResponse struct {
}
func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) {
if strings.TrimSpace(cfg.AccessToken) == "" {
return nil, errors.New("personal source: AccessToken is required")
if cfg.AccessTokenProvider == nil && strings.TrimSpace(cfg.AccessToken) == "" {
return nil, errors.New("personal source: AccessToken or AccessTokenProvider is required")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return nil, errors.New("personal source: ClientID is required")
@@ -192,6 +204,31 @@ func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) (
}
func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) {
accessToken, err := resolveSourceAccessToken(ctx, s.cfg.AccessTokenProvider, s.cfg.AccessToken, "personal source")
if err != nil {
// Transient provider failures (network, 429, 5xx) must not kill a
// long-running source; the reconnect loop retries after backoff.
if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient {
return nil, retryPersonal(err)
}
return nil, err
}
ticket, status, err := s.fetchTicketAttempt(ctx, accessToken)
if status == http.StatusUnauthorized && s.cfg.ForceRefreshToken != nil {
refreshed, refreshErr := refreshRejectedSourceToken(ctx, s.cfg.ForceRefreshToken, accessToken, "personal source", err)
if refreshErr != nil {
if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient {
return nil, retryPersonal(refreshErr)
}
return nil, refreshErr
}
// Retry once with the freshly rotated token; a second 401 stays fatal.
ticket, _, err = s.fetchTicketAttempt(ctx, refreshed)
}
return ticket, err
}
func (s *PersonalSource) fetchTicketAttempt(ctx context.Context, accessToken string) (*ticketResponse, int, error) {
body := map[string]any{
"sourceId": s.cfg.SourceID,
"mode": s.cfg.TicketMode,
@@ -203,39 +240,78 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TicketURL, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("personal source: create ticket request: %w", err)
return nil, 0, fmt.Errorf("personal source: create ticket request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("x-user-access-token", s.cfg.AccessToken)
req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken)
req.Header.Set("x-user-access-token", accessToken)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID)
req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID)
resp, err := s.cfg.HTTPClient.Do(req)
if err != nil {
return nil, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err))
return nil, 0, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err))
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Classify by status before touching the body: a truncated error body
// must not upgrade a fatal status (notably 401) into a retryable
// error, or the outer reconnect loop would bypass the single
// refresh-retry guard. The body is not used here, so drain it only
// best-effort for connection reuse.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, config.MaxResponseBodySize))
err := fmt.Errorf("personal source: ticket HTTP %d", resp.StatusCode)
if retryableTicketStatus(resp.StatusCode) {
return nil, retryPersonal(err)
return nil, resp.StatusCode, retryPersonal(err)
}
return nil, err
return nil, resp.StatusCode, err
}
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
if err != nil {
return nil, resp.StatusCode, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err))
}
ticket, err := decodeTicket(data)
if err != nil {
return nil, err
return nil, resp.StatusCode, err
}
if ticket.Endpoint == "" || ticket.Ticket == "" {
return nil, errors.New("personal source: ticket response missing endpoint or ticket")
return nil, resp.StatusCode, errors.New("personal source: ticket response missing endpoint or ticket")
}
return ticket, nil
return ticket, resp.StatusCode, nil
}
// refreshRejectedSourceToken funnels a server-side 401 into the optional
// force-refresh callback. It hands the actual rejected token to the caller's
// compare-and-refresh logic and returns the rotated token for an immediate
// retry. Refresh failures keep the original 401 as context instead of being
// dropped.
func refreshRejectedSourceToken(ctx context.Context, refresh ForceRefreshTokenFn, rejectedToken, component string, cause error) (string, error) {
token, err := refresh(ctx, rejectedToken)
if err != nil {
return "", fmt.Errorf("%s: refresh rejected access token: %w", component, errors.Join(cause, err))
}
if token = strings.TrimSpace(token); token == "" {
return "", fmt.Errorf("%s: refresh rejected access token returned empty token: %w", component, cause)
}
return token, nil
}
func resolveSourceAccessToken(ctx context.Context, provider AccessTokenProvider, fallback, component string) (string, error) {
if provider != nil {
token, err := provider(ctx)
if err != nil {
return "", fmt.Errorf("%s: resolve access token: %w", component, err)
}
if token = strings.TrimSpace(token); token != "" {
return token, nil
}
return "", fmt.Errorf("%s: access token provider returned empty token", component)
}
if token := strings.TrimSpace(fallback); token != "" {
return token, nil
}
return "", fmt.Errorf("%s: access token is required", component)
}
func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error {
@@ -357,6 +433,13 @@ func isRetryablePersonalError(err error) bool {
func personalRetryLogError(err error) string {
message := err.Error()
switch {
case strings.Contains(message, "resolve access token"), strings.Contains(message, "refresh rejected access token"):
// Token resolution/refresh errors may carry provider details; log
// only the structured HTTP status.
if status := refreshHTTPStatus(err); status != 0 {
return fmt.Sprintf("personal source: token refresh HTTP %d", status)
}
return "personal source: token refresh: temporary network error"
case strings.Contains(message, "ticket HTTP"):
return message
case strings.Contains(message, "fetch ticket"):
@@ -374,6 +457,14 @@ func personalRetryLogError(err error) string {
}
}
func refreshHTTPStatus(err error) int {
var statusErr *authpkg.HTTPStatusError
if !errors.As(err, &statusErr) || statusErr == nil {
return 0
}
return statusErr.StatusCode
}
func retryableTicketStatus(status int) bool {
return status == http.StatusRequestTimeout ||
status == http.StatusTooManyRequests ||
+170 -37
View File
@@ -20,11 +20,13 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/gorilla/websocket"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
@@ -33,20 +35,56 @@ import (
const (
PortalTicketModeNormal = "normal"
PortalTicketModeCustom = "custom"
portalReconnectMin = time.Second
portalReconnectMax = 30 * time.Second
)
// PortalTicketConfig describes the portal-managed user Stream ticket flow.
// normal mode uses portal-side managed credentials; custom mode asks portal to
// open the user connection with the caller-provided clientId/clientSecret.
type PortalTicketConfig struct {
TicketURL string
AccessToken string
SourceID string
Mode string
ClientID string
ClientSecret string
UserAgent string
HTTPClient *http.Client
TicketURL string
AccessToken string
AccessTokenProvider AccessTokenProvider
ForceRefreshToken ForceRefreshTokenFn
SourceID string
Mode string
ClientID string
ClientSecret string
UserAgent string
HTTPClient *http.Client
WebSocketDialer *websocket.Dialer
ReconnectMin time.Duration
ReconnectMax time.Duration
DisableReconnect bool
}
// portalStageError tags a portal stream failure with the stage it happened
// in and whether the reconnect loop may retry it. Error() stays free of
// untrusted response content so it is safe to log on every reconnect.
type portalStageError struct {
stage string
status int
retryable bool
cause error
}
func (e *portalStageError) Error() string {
if e == nil {
return "source: portal stream failed"
}
message := "source: portal " + strings.ReplaceAll(strings.TrimSpace(e.stage), "_", " ") + " failed"
if e.status != 0 {
message += fmt.Sprintf(" (HTTP %d)", e.status)
}
return message
}
func (e *portalStageError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error {
@@ -60,8 +98,8 @@ func (c *PortalTicketConfig) Valid() error {
if strings.TrimSpace(c.TicketURL) == "" {
return errors.New("source: portal ticket URL is required")
}
if strings.TrimSpace(c.AccessToken) == "" {
return errors.New("source: portal access token is required")
if c.AccessTokenProvider == nil && strings.TrimSpace(c.AccessToken) == "" {
return errors.New("source: portal access token or provider is required")
}
if strings.TrimSpace(c.SourceID) == "" {
return errors.New("source: portal sourceId is required")
@@ -96,48 +134,102 @@ func normalizePortalTicketMode(mode string) string {
func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.EmitFn) error {
s.machine.OnConnecting()
defer s.machine.OnStopped()
minBackoff := s.cfg.PortalTicket.ReconnectMin
if minBackoff <= 0 {
minBackoff = portalReconnectMin
}
maxBackoff := s.cfg.PortalTicket.ReconnectMax
if maxBackoff <= 0 {
maxBackoff = portalReconnectMax
}
if maxBackoff < minBackoff {
maxBackoff = minBackoff
}
backoff := minBackoff
for {
acked, err := s.runPortalTicketAttempt(ctx, emit)
if ctx.Err() != nil {
return ctx.Err()
}
var stageErr *portalStageError
if !errors.As(err, &stageErr) || stageErr == nil || !stageErr.retryable || s.cfg.PortalTicket.DisableReconnect {
return err
}
if acked {
backoff = minBackoff
}
s.machine.OnReconnect()
slog.Warn("portal source reconnecting",
"stage", stageErr.stage,
"http_status", stageErr.status,
"error_type", fmt.Sprintf("%T", stageErr.cause),
"retry_in", backoff,
"reconnect_count", s.machine.Snapshot().ReconnectCount,
)
if err := waitPersonalReconnect(ctx, backoff); err != nil {
return err
}
backoff = nextPersonalBackoff(backoff, maxBackoff)
}
}
func (s *DingtalkSource) runPortalTicketAttempt(ctx context.Context, emit dwsevent.EmitFn) (bool, error) {
ticket, err := requestPortalTicket(ctx, s.cfg.PortalTicket)
if err != nil {
s.machine.OnStopped()
return err
return false, err
}
wsURL, err := websocketURL(ticket)
if err != nil {
s.machine.OnStopped()
return err
return false, err
}
userAgent := strings.TrimSpace(s.cfg.PortalTicket.UserAgent)
if userAgent == "" {
userAgent = "dws-event-consume"
}
conn, resp, err := (&websocket.Dialer{HandshakeTimeout: 20 * time.Second}).DialContext(ctx, wsURL, http.Header{
dialer := s.cfg.PortalTicket.WebSocketDialer
if dialer == nil {
dialer = &websocket.Dialer{HandshakeTimeout: 20 * time.Second}
}
conn, resp, err := dialer.DialContext(ctx, wsURL, http.Header{
"User-Agent": []string{userAgent},
})
if err != nil {
s.machine.OnStopped()
status := 0
cause := fmt.Errorf("source: portal stream connect: %w", err)
if resp != nil {
defer resp.Body.Close()
status = resp.StatusCode
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return fmt.Errorf("source: portal stream connect HTTP %d: %s: %w",
cause = fmt.Errorf("source: portal stream connect HTTP %d: %s: %w",
resp.StatusCode, truncatePortalTicketLog(string(raw), 300), err)
}
return fmt.Errorf("source: portal stream connect: %w", err)
return false, &portalStageError{
stage: "stream_connect",
status: status,
retryable: status == 0 || retryableTicketStatus(status),
cause: cause,
}
}
defer conn.Close()
attemptCtx, cancel := context.WithCancel(ctx)
defer func() {
cancel()
_ = conn.Close()
}()
closeOnContext(attemptCtx, conn)
s.machine.OnConnected()
closeOnContext(ctx, conn)
handler := s.makeHandler(emit)
acked := false
for {
_, message, err := conn.ReadMessage()
if err != nil {
s.machine.OnStopped()
if isContextDone(ctx) {
return ctx.Err()
return acked, ctx.Err()
}
return fmt.Errorf("source: portal stream read: %w", err)
return acked, &portalStageError{stage: "stream_read", retryable: true, cause: fmt.Errorf("source: portal stream read: %w", err)}
}
df, err := payload.DecodeDataFrame(message)
if err != nil {
@@ -146,12 +238,12 @@ func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.Em
resp, _ := handler(ctx, df)
ensurePortalAckHeaders(resp, df)
if err := portalWriteMessage(conn, websocket.TextMessage, resp.Encode()); err != nil {
s.machine.OnStopped()
if isContextDone(ctx) {
return ctx.Err()
return acked, ctx.Err()
}
return fmt.Errorf("source: portal stream ack: %w", err)
return acked, &portalStageError{stage: "stream_ack", retryable: true, cause: fmt.Errorf("source: portal stream ack: %w", err)}
}
acked = true
}
}
@@ -161,10 +253,35 @@ type portalStreamTicket struct {
}
func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) {
accessToken, err := resolveSourceAccessToken(ctx, cfg.AccessTokenProvider, cfg.AccessToken, "source: portal ticket")
if err != nil {
// Transient provider failures (network, 429, 5xx) must not kill a
// long-running source; the reconnect loop retries after backoff.
if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient {
return portalStreamTicket{}, &portalStageError{stage: "ticket_auth", status: refreshHTTPStatus(err), retryable: true, cause: err}
}
return portalStreamTicket{}, err
}
httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
ticket, status, err := requestPortalTicketAttempt(ctx, cfg, httpClient, accessToken)
if status == http.StatusUnauthorized && cfg.ForceRefreshToken != nil {
refreshed, refreshErr := refreshRejectedSourceToken(ctx, cfg.ForceRefreshToken, accessToken, "source: portal ticket", err)
if refreshErr != nil {
if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient {
return portalStreamTicket{}, &portalStageError{stage: "ticket_auth_refresh", status: refreshHTTPStatus(refreshErr), retryable: true, cause: refreshErr}
}
return portalStreamTicket{}, refreshErr
}
// Retry once with the freshly rotated token; a second 401 stays fatal.
ticket, _, err = requestPortalTicketAttempt(ctx, cfg, httpClient, refreshed)
}
return ticket, err
}
func requestPortalTicketAttempt(ctx context.Context, cfg *PortalTicketConfig, httpClient *http.Client, accessToken string) (portalStreamTicket, int, error) {
body := map[string]string{
"sourceId": strings.TrimSpace(cfg.SourceID),
"channelType": strings.TrimSpace(cfg.SourceID),
@@ -177,29 +294,45 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt
rawBody, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.TicketURL), bytes.NewReader(rawBody))
if err != nil {
return portalStreamTicket{}, err
return portalStreamTicket{}, 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if ua := strings.TrimSpace(cfg.UserAgent); ua != "" {
req.Header.Set("User-Agent", ua)
}
req.Header.Set("x-user-access-token", cfg.AccessToken)
req.Header.Set("x-user-access-token", accessToken)
resp, err := httpClient.Do(req)
if err != nil {
return portalStreamTicket{}, fmt.Errorf("source: portal ticket request: %w", err)
return portalStreamTicket{}, 0, &portalStageError{stage: "ticket_request", retryable: true, cause: fmt.Errorf("source: portal ticket request: %w", err)}
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 400 {
return portalStreamTicket{}, fmt.Errorf("source: portal ticket HTTP %d: %s",
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Preserve HTTP status semantics before touching the body. A truncated
// 401 body must not become a retryable read error that bypasses the
// single token-refresh guard; the body is only best-effort diagnostics.
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
httpErr := fmt.Errorf("source: portal ticket HTTP %d: %s",
resp.StatusCode, truncatePortalTicketLog(string(raw), 300))
if retryableTicketStatus(resp.StatusCode) {
return portalStreamTicket{}, resp.StatusCode, &portalStageError{stage: "ticket_request", status: resp.StatusCode, retryable: true, cause: httpErr}
}
return portalStreamTicket{}, resp.StatusCode, httpErr
}
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return portalStreamTicket{}, resp.StatusCode, &portalStageError{
stage: "ticket_request",
status: resp.StatusCode,
retryable: true,
cause: fmt.Errorf("source: portal ticket read: %w", readErr),
}
}
var direct portalStreamTicket
if err := json.Unmarshal(raw, &direct); err == nil && direct.Endpoint != "" && direct.Ticket != "" {
return direct, nil
return direct, resp.StatusCode, nil
}
var envelope struct {
@@ -209,16 +342,16 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt
ErrorMsg string `json:"errorMsg"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return portalStreamTicket{}, fmt.Errorf("source: portal ticket parse: %w", err)
return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket parse: %w", err)
}
if !envelope.Success {
return portalStreamTicket{}, fmt.Errorf("source: portal ticket failed: %s %s",
return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket failed: %s %s",
envelope.ErrorCode, envelope.ErrorMsg)
}
if envelope.Result.Endpoint == "" || envelope.Result.Ticket == "" {
return portalStreamTicket{}, errors.New("source: portal ticket result missing endpoint/ticket")
return portalStreamTicket{}, resp.StatusCode, errors.New("source: portal ticket result missing endpoint/ticket")
}
return envelope.Result, nil
return envelope.Result, resp.StatusCode, nil
}
func websocketURL(ticket portalStreamTicket) (string, error) {
@@ -0,0 +1,485 @@
package source
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/gorilla/websocket"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
)
// TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd drives the full production chain
// DingtalkSource.Start → startPortalTicket → requestPortalTicket: the first
// ticket request is rejected with 401, ForceRefreshToken rotates the token,
// the in-chain retry succeeds with the fresh token and a WebSocket event is
// delivered to emit.
func TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd(t *testing.T) {
var ticketCalls, refreshCalls atomic.Int64
var rejectedSeen atomic.Value
upgrader := websocket.Upgrader{}
var wsURL string
mux := http.NewServeMux()
mux.HandleFunc("/ticket", func(w http.ResponseWriter, r *http.Request) {
ticketCalls.Add(1)
switch r.Header.Get("x-user-access-token") {
case "fresh-token":
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]string{"endpoint": wsURL, "ticket": "ticket-1"},
})
default:
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, "token expired")
}
})
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "msg-1"}, Data: `{}`}
_ = conn.WriteJSON(df)
_, _, _ = conn.ReadMessage()
})
srv := httptest.NewServer(mux)
defer srv.Close()
wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
s, err := New(Config{PortalTicket: &PortalTicketConfig{
TicketURL: srv.URL + "/ticket",
AccessTokenProvider: func(context.Context) (string, error) {
return "stale-token", nil
},
ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) {
refreshCalls.Add(1)
rejectedSeen.Store(rejectedToken)
return "fresh-token", nil
},
SourceID: "source",
HTTPClient: srv.Client(),
}})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
emitted := make(chan struct{}, 1)
done := make(chan error, 1)
go func() { done <- s.Start(ctx, func(*dwsevent.RawEvent) { emitted <- struct{}{} }) }()
select {
case <-emitted:
case <-time.After(2 * time.Second):
t.Fatal("portal event timeout after 401 refresh retry")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("portal stop = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("portal stop timeout")
}
if got := ticketCalls.Load(); got != 2 {
t.Fatalf("ticket calls = %d, want 2", got)
}
if got := refreshCalls.Load(); got != 1 {
t.Fatalf("refresh calls = %d, want 1", got)
}
if got, _ := rejectedSeen.Load().(string); got != "stale-token" {
t.Fatalf("rejected token = %q, want %q", got, "stale-token")
}
}
// TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly asserts the in-chain
// retry sends the token returned by ForceRefreshToken instead of re-invoking
// the provider (which could still serve the stale token).
func TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly(t *testing.T) {
providerCalls := 0
var attemptTokens []string
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
token := req.Header.Get("x-user-access-token")
attemptTokens = append(attemptTokens, token)
if token != "rotated" {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://x","ticket":"t"}`)), Header: make(http.Header)}, nil
})}
ticket, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
TicketURL: "https://x",
AccessTokenProvider: func(context.Context) (string, error) {
providerCalls++
return "stale", nil
},
ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) {
if rejectedToken != "stale" {
t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale")
}
return "rotated", nil
},
SourceID: "s",
HTTPClient: client,
})
if err != nil {
t.Fatalf("requestPortalTicket = %v", err)
}
if ticket.Endpoint != "wss://x" || ticket.Ticket != "t" {
t.Fatalf("ticket = %#v", ticket)
}
if providerCalls != 1 {
t.Fatalf("provider calls = %d, want 1", providerCalls)
}
if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" {
t.Fatalf("attempt tokens = %v", attemptTokens)
}
}
// TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors asserts a failing
// refresh neither retries nor drops the refresh error or the original 401.
func TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors(t *testing.T) {
refreshErr := errors.New("refresh_token exchange failed")
attempts := 0
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
attempts++
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
})}
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
TicketURL: "https://x",
AccessToken: "stale",
ForceRefreshToken: func(context.Context, string) (string, error) {
return "", refreshErr
},
SourceID: "s",
HTTPClient: client,
})
if !errors.Is(err, refreshErr) {
t.Fatalf("error should wrap refresh error, got %v", err)
}
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
t.Fatalf("error should keep original 401, got %v", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1 (no retry after failed refresh)", attempts)
}
}
// TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal covers backward
// compatibility: nil ForceRefreshToken keeps the single-attempt fatal 401.
func TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal(t *testing.T) {
attempts := 0
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
attempts++
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
})}
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
TicketURL: "https://x", AccessToken: "stale", SourceID: "s", HTTPClient: client,
})
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
t.Fatalf("fatal 401 expected, got %v", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
// TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal guards against refresh loops: the
// controlled retry happens exactly once even if the rotated token is also
// rejected.
func TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal(t *testing.T) {
attempts := 0
refreshCalls := 0
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
attempts++
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
})}
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
TicketURL: "https://x",
AccessToken: "stale",
ForceRefreshToken: func(context.Context, string) (string, error) {
refreshCalls++
return "rotated-but-still-rejected", nil
},
SourceID: "s",
HTTPClient: client,
})
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
t.Fatalf("fatal 401 expected after single retry, got %v", err)
}
if attempts != 2 || refreshCalls != 1 {
t.Fatalf("attempts = %d refreshCalls = %d, want 2/1", attempts, refreshCalls)
}
}
// TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal asserts an empty rotated
// token is rejected instead of being sent to the server.
func TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal(t *testing.T) {
attempts := 0
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
attempts++
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
})}
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
TicketURL: "https://x",
AccessToken: "stale",
ForceRefreshToken: func(context.Context, string) (string, error) {
return " ", nil
},
SourceID: "s",
HTTPClient: client,
})
if err == nil || !strings.Contains(err.Error(), "empty token") {
t.Fatalf("empty rotated token error expected, got %v", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
// TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry mirrors the portal behavior for the
// personal stream ticket path.
func TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry(t *testing.T) {
var attemptTokens []string
src, err := NewPersonal(PersonalConfig{
AccessTokenProvider: func(context.Context) (string, error) { return "stale", nil },
ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) {
if rejectedToken != "stale" {
t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale")
}
return "rotated", nil
},
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
token := req.Header.Get("x-user-access-token")
attemptTokens = append(attemptTokens, token)
if token != "rotated" {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil
})},
})
if err != nil {
t.Fatal(err)
}
ticket, err := src.fetchTicket(context.Background())
if err != nil {
t.Fatalf("fetchTicket = %v", err)
}
if ticket.Endpoint != "wss://stream.test" || ticket.Ticket != "ticket" {
t.Fatalf("ticket = %#v", ticket)
}
if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" {
t.Fatalf("attempt tokens = %v", attemptTokens)
}
}
// TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal asserts a failed refresh
// keeps the 401 fatal (not retryable) and wraps the refresh error.
func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(t *testing.T) {
refreshErr := errors.New("refresh_token exchange failed")
src, err := NewPersonal(PersonalConfig{
AccessToken: "stale",
ForceRefreshToken: func(context.Context, string) (string, error) {
return "", refreshErr
},
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil
})},
})
if err != nil {
t.Fatal(err)
}
_, err = src.fetchTicket(context.Background())
if !errors.Is(err, refreshErr) {
t.Fatalf("error should wrap refresh error, got %v", err)
}
if isRetryablePersonalError(err) {
t.Fatalf("failed refresh should stay fatal, got retryable %v", err)
}
}
// brokenBody simulates a response body that fails mid-read, e.g. the server
// closing the connection before the error payload is fully written.
type brokenBody struct{}
func (brokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
func (brokenBody) Close() error { return nil }
func TestCrossPlatformCoveragePortalTicketNon2xxTruncatedBodyKeepsStatus(t *testing.T) {
for _, tc := range []struct {
status int
retryable bool
}{
{status: http.StatusUnauthorized},
{status: http.StatusTooManyRequests, retryable: true},
{status: http.StatusServiceUnavailable, retryable: true},
} {
t.Run(http.StatusText(tc.status), func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: tc.status, Body: brokenBody{}, Header: make(http.Header)}, nil
})}
_, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{
TicketURL: "https://ticket.test",
SourceID: "source",
}, client, "token")
if status != tc.status || err == nil || !strings.Contains(err.Error(), fmt.Sprintf("HTTP %d", tc.status)) {
t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want HTTP %d", status, err, tc.status)
}
if errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("non-2xx status error should not expose diagnostic body read failure: %v", err)
}
var stageErr *portalStageError
if tc.retryable {
if !errors.As(err, &stageErr) || stageErr.status != tc.status || !stageErr.retryable {
t.Fatalf("retryable status should return retryable portalStageError, got %T %v", err, err)
}
} else if errors.As(err, &stageErr) && stageErr.retryable {
t.Fatalf("fatal status should not become retryable stage error: %v", err)
}
})
}
}
func TestCrossPlatformCoveragePortalTicket200TruncatedBodyIsRetryable(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: brokenBody{}, Header: make(http.Header)}, nil
})}
_, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{
TicketURL: "https://ticket.test",
SourceID: "source",
}, client, "token")
if status != http.StatusOK || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want 200 + io.ErrUnexpectedEOF", status, err)
}
var stageErr *portalStageError
if !errors.As(err, &stageErr) || !stageErr.retryable || stageErr.stage != "ticket_request" {
t.Fatalf("2xx truncated body should be retryable ticket_request stage, got %T %v", err, err)
}
}
// TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal guards the single
// refresh-retry protection: a 401 whose body fails with unexpected EOF must
// be classified by status (fatal) and never wrapped as retryable, otherwise
// the outer reconnect loop would refresh again on every iteration.
func TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal(t *testing.T) {
attempts := 0
refreshCalls := 0
src, err := NewPersonal(PersonalConfig{
AccessToken: "stale",
ForceRefreshToken: func(context.Context, string) (string, error) {
refreshCalls++
return "rotated", nil
},
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
attempts++
return &http.Response{StatusCode: http.StatusUnauthorized, Body: brokenBody{}, Header: make(http.Header)}, nil
})},
})
if err != nil {
t.Fatal(err)
}
_, err = src.fetchTicket(context.Background())
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
t.Fatalf("fatal 401 error expected, got %v", err)
}
if isRetryablePersonalError(err) {
t.Fatalf("401 with truncated body must stay fatal, got retryable %v", err)
}
if refreshCalls != 1 {
t.Fatalf("refresh calls = %d, want 1", refreshCalls)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
// TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable pins the existing
// behavior for success responses: a body read failure on 2xx is a transient
// transport problem and remains retryable.
func TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable(t *testing.T) {
src, err := NewPersonal(PersonalConfig{
AccessToken: "token",
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: brokenBody{}, Header: make(http.Header)}, nil
})},
})
if err != nil {
t.Fatal(err)
}
_, err = src.fetchTicket(context.Background())
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("error should wrap io.ErrUnexpectedEOF, got %v", err)
}
if !isRetryablePersonalError(err) {
t.Fatalf("2xx body read failure should stay retryable, got %v", err)
}
}
// TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges covers the
// remaining fetchTicketAttempt branches: transport failures and retryable
// statuses stay retryable, while a well-formed response missing the endpoint
// or ticket fields stays fatal.
func TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges(t *testing.T) {
newSource := func(rt roundTripFunc) *PersonalSource {
src, err := NewPersonal(PersonalConfig{
AccessToken: "token",
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: rt},
})
if err != nil {
t.Fatal(err)
}
return src
}
dialErr := errors.New("dial tcp: connection refused")
src := newSource(func(*http.Request) (*http.Response, error) { return nil, dialErr })
_, _, err := src.fetchTicketAttempt(context.Background(), "token")
if !errors.Is(err, dialErr) || !isRetryablePersonalError(err) {
t.Fatalf("transport failure should stay retryable and wrap cause, got %v", err)
}
src = newSource(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("busy")), Header: make(http.Header)}, nil
})
_, status, err := src.fetchTicketAttempt(context.Background(), "token")
if status != http.StatusServiceUnavailable || !isRetryablePersonalError(err) {
t.Fatalf("503 should stay retryable, got status %d err %v", status, err)
}
src = newSource(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"","ticket":""}`)), Header: make(http.Header)}, nil
})
_, _, err = src.fetchTicketAttempt(context.Background(), "token")
if err == nil || isRetryablePersonalError(err) || !strings.Contains(err.Error(), "missing endpoint or ticket") {
t.Fatalf("missing ticket fields should stay fatal, got %v", err)
}
}
@@ -0,0 +1,66 @@
package source
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
)
type tokenProviderRoundTripper func(*http.Request) (*http.Response, error)
func (f tokenProviderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestCrossPlatformCoveragePersonalSourceResolvesTokenForEveryTicketRequest(t *testing.T) {
tokens := []string{"token-a", "token-b"}
calls := 0
source, err := NewPersonal(PersonalConfig{
AccessTokenProvider: func(context.Context) (string, error) {
token := tokens[calls]
calls++
return token, nil
},
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(req *http.Request) (*http.Response, error) {
want := tokens[calls-1]
if got := req.Header.Get("x-user-access-token"); got != want {
t.Fatalf("token header = %q, want %q", got, want)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil
})},
})
if err != nil {
t.Fatal(err)
}
for range 2 {
if _, err := source.fetchTicket(context.Background()); err != nil {
t.Fatal(err)
}
}
if calls != 2 {
t.Fatalf("provider calls = %d, want 2", calls)
}
}
func TestCrossPlatformCoveragePortalTicketProviderFailureStopsBeforeHTTP(t *testing.T) {
want := errors.New("token store failed")
httpCalled := false
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
TicketURL: "https://ticket.test",
AccessTokenProvider: func(context.Context) (string, error) { return "", want },
SourceID: "source",
HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(*http.Request) (*http.Response, error) {
httpCalled = true
return nil, errors.New("unexpected HTTP")
})},
})
if !errors.Is(err, want) || httpCalled {
t.Fatalf("request error = %v, httpCalled=%v", err, httpCalled)
}
}
@@ -0,0 +1,295 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package source
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/gorilla/websocket"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
)
func transientRefreshError() error {
return &authpkg.HTTPStatusError{StatusCode: http.StatusServiceUnavailable}
}
func terminalRefreshError() error {
return &authpkg.HTTPStatusError{StatusCode: http.StatusUnauthorized}
}
func TestCrossPlatformCoveragePersonalRetryLogErrorReportsOnlySafeTransientStatus(t *testing.T) {
cause := errors.New("personal source: ticket HTTP 401 secret detail")
_, err := refreshRejectedSourceToken(context.Background(), func(context.Context, string) (string, error) {
return "", transientRefreshError()
}, "rejected", "personal source", cause)
if got, want := personalRetryLogError(retryPersonal(err)), "personal source: token refresh HTTP 503"; got != want {
t.Fatalf("personalRetryLogError() = %q, want %q", got, want)
}
}
func TestCrossPlatformCoveragePersonalSourceRetriesTransientTokenResolutionFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
src, err := NewPersonal(PersonalConfig{
AccessTokenProvider: func(context.Context) (string, error) {
if calls.Add(1) == 2 {
cancel()
}
return "", transientRefreshError()
},
ClientID: "client",
SourceID: "open",
TicketURL: "https://ticket.invalid",
ReconnectMin: time.Millisecond,
ReconnectMax: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Start() error = %v, want context canceled after retry", err)
}
if calls.Load() != 2 || src.State().ReconnectCount != 1 {
t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount)
}
}
func TestCrossPlatformCoveragePersonalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) {
var calls atomic.Int32
src, err := NewPersonal(PersonalConfig{
AccessTokenProvider: func(context.Context) (string, error) {
calls.Add(1)
return "", terminalRefreshError()
},
ClientID: "client",
SourceID: "open",
TicketURL: "https://ticket.invalid",
})
if err != nil {
t.Fatal(err)
}
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal {
t.Fatalf("Start() error = %v, want terminal refresh failure", err)
}
if calls.Load() != 1 || src.State().ReconnectCount != 0 {
t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount)
}
}
func TestCrossPlatformCoveragePersonalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var ticketCalls atomic.Int32
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
ticketCalls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
src, err := NewPersonal(PersonalConfig{
AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil },
ForceRefreshToken: func(_ context.Context, rejected string) (string, error) {
if rejected != "old-token" {
t.Fatalf("rejected token = %q, want old-token", rejected)
}
if refreshCalls.Add(1) == 2 {
cancel()
}
return "", transientRefreshError()
},
ClientID: "client",
SourceID: "open",
TicketURL: srv.URL,
HTTPClient: srv.Client(),
ReconnectMin: time.Millisecond,
ReconnectMax: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Start() error = %v, want context canceled after retry", err)
}
if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 {
t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount)
}
}
func TestCrossPlatformCoveragePersonalRetryLogErrorFallsBackToNetworkMessage(t *testing.T) {
err := retryPersonal(fmt.Errorf("personal source: resolve access token: %w", errors.New("dial tcp: lookup oauth.invalid")))
if got, want := personalRetryLogError(err), "personal source: token refresh: temporary network error"; got != want {
t.Fatalf("personalRetryLogError() = %q, want %q", got, want)
}
}
func TestCrossPlatformCoveragePortalStageErrorNilAndUnwrap(t *testing.T) {
var nilErr *portalStageError
if got, want := nilErr.Error(), "source: portal stream failed"; got != want {
t.Fatalf("nil stage error = %q, want %q", got, want)
}
if nilErr.Unwrap() != nil {
t.Fatal("nil stage error should unwrap to nil")
}
cause := errors.New("cause")
stageErr := &portalStageError{stage: "stream_read", retryable: true, cause: cause}
if !errors.Is(stageErr, cause) {
t.Fatalf("stage error should unwrap to cause: %v", stageErr)
}
}
func TestCrossPlatformCoveragePortalSourceRetriesTransientTokenResolutionFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
src, err := New(Config{PortalTicket: &PortalTicketConfig{
TicketURL: "https://ticket.invalid",
AccessTokenProvider: func(context.Context) (string, error) {
if calls.Add(1) == 2 {
cancel()
}
return "", transientRefreshError()
},
SourceID: "open",
// Min above max exercises the reconnect clamp.
ReconnectMin: 2 * time.Millisecond,
ReconnectMax: time.Millisecond,
}})
if err != nil {
t.Fatal(err)
}
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Start() error = %v, want context canceled after retry", err)
}
if calls.Load() != 2 || src.State().ReconnectCount != 1 {
t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount)
}
}
func TestCrossPlatformCoveragePortalSourceResetsBackoffAfterAckedAttempt(t *testing.T) {
var ticketCalls atomic.Int32
upgrader := websocket.Upgrader{}
var wsURL string
mux := http.NewServeMux()
mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) {
if ticketCalls.Add(1) > 1 {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = io.WriteString(w, `{"endpoint":`+strconvQuote(wsURL)+`,"ticket":"t"}`)
})
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "m"}, Data: `{}`}
_ = conn.WriteJSON(df)
// Wait for the ACK, then close so the read fails retryably with an
// acked attempt behind it, which resets the reconnect backoff.
_, _, _ = conn.ReadMessage()
})
srv := httptest.NewServer(mux)
defer srv.Close()
wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
src, err := New(Config{PortalTicket: &PortalTicketConfig{
TicketURL: srv.URL + "/ticket",
AccessToken: "t",
SourceID: "open",
HTTPClient: srv.Client(),
ReconnectMin: time.Millisecond,
ReconnectMax: time.Millisecond,
}})
if err != nil {
t.Fatal(err)
}
var events atomic.Int32
err = src.Start(context.Background(), func(*dwsevent.RawEvent) { events.Add(1) })
if err == nil || !strings.Contains(err.Error(), "portal ticket HTTP 400") {
t.Fatalf("Start() error = %v, want fatal ticket HTTP 400 after reconnect", err)
}
if events.Load() != 1 || ticketCalls.Load() != 2 || src.State().ReconnectCount != 1 {
t.Fatalf("events=%d ticket calls=%d reconnects=%d, want 1/2/1", events.Load(), ticketCalls.Load(), src.State().ReconnectCount)
}
}
func TestCrossPlatformCoveragePortalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) {
var calls atomic.Int32
src, err := New(Config{PortalTicket: &PortalTicketConfig{
TicketURL: "https://ticket.invalid",
AccessTokenProvider: func(context.Context) (string, error) {
calls.Add(1)
return "", terminalRefreshError()
},
SourceID: "open",
}})
if err != nil {
t.Fatal(err)
}
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal {
t.Fatalf("Start() error = %v, want terminal refresh failure", err)
}
if calls.Load() != 1 || src.State().ReconnectCount != 0 {
t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount)
}
}
func TestCrossPlatformCoveragePortalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var ticketCalls atomic.Int32
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
ticketCalls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
src, err := New(Config{PortalTicket: &PortalTicketConfig{
TicketURL: srv.URL,
AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil },
ForceRefreshToken: func(_ context.Context, rejected string) (string, error) {
if rejected != "old-token" {
t.Fatalf("rejected token = %q, want old-token", rejected)
}
if refreshCalls.Add(1) == 2 {
cancel()
}
return "", transientRefreshError()
},
SourceID: "open",
HTTPClient: srv.Client(),
ReconnectMin: time.Millisecond,
ReconnectMax: time.Millisecond,
}})
if err != nil {
t.Fatal(err)
}
err = src.Start(ctx, func(*dwsevent.RawEvent) {})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Start() error = %v, want context canceled after retry", err)
}
if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 {
t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount)
}
}
+7 -4
View File
@@ -116,11 +116,14 @@ func NewWorkflowInvocation(legacyPath, workflowName string, steps []Invocation)
func MergePayloads(jsonPayload, paramsPayload string, overrides map[string]any) (map[string]any, error) {
merged := make(map[string]any)
for label, payload := range map[string]string{
"--json": jsonPayload,
"--params": paramsPayload,
for _, payload := range []struct {
label string
raw string
}{
{label: "--json", raw: jsonPayload},
{label: "--params", raw: paramsPayload},
} {
value, err := parseJSONObject(label, payload)
value, err := parseJSONObject(payload.label, payload.raw)
if err != nil {
return nil, err
}
+4
View File
@@ -52,6 +52,10 @@ func TestCrossPlatformCoverageMergePayloadsAndToolCallRequest(t *testing.T) {
if empty, err := MergePayloads(" ", "", nil); err != nil || len(empty) != 0 {
t.Fatalf("empty MergePayloads() = %#v, %v", empty, err)
}
merged, err = MergePayloads(`{"same":"json"}`, `{"same":"params"}`, nil)
if err != nil || merged["same"] != "params" {
t.Fatalf("MergePayloads() precedence = %#v, %v; want --params to win", merged, err)
}
for _, input := range []string{`{`, `[]`, `null`} {
if _, err := MergePayloads(input, "", nil); err == nil {
t.Errorf("MergePayloads(%q) error = nil", input)
@@ -18,7 +18,7 @@ func TestCrossPlatformCoverageGenerateProductionAgentMetadataPipeline(t *testing
if err != nil {
t.Fatal(err)
}
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
t.Fatal(err)
}
@@ -46,7 +46,7 @@ var (
writeMetadataFileBytes = os.WriteFile
writeMetadataJSON = writeJSON
newMetadataRoot = app.NewRootCommand
newMetadataRoot = app.NewSchemaSourceRootCommand
buildEffectiveMetadata = cli.BuildEffectiveCommandRegistry
bindEffectiveMetadata = cli.BindEffectiveCommandRegistry
loadSelectionMetadataHints = cli.LoadAgentHintsFromSelectionForValidation
@@ -48,7 +48,7 @@ func main() {
fail(err)
}
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
if err := generateSchemaCatalog(root, resolvedSurfacePath, outputPath); err != nil {
fail(err)
}
@@ -131,7 +131,7 @@ func TestCrossPlatformCoverageGenerateSchemaCatalogFailureEdges(t *testing.T) {
}
func TestCrossPlatformCoverageGenerateSchemaCatalogResolvesBuildExactlyOnce(t *testing.T) {
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
resolveCalls := 0
resolvedRegistryHash := ""
resolver := func(candidate *cobra.Command) (cli.ResolvedSchemaBuild, error) {
@@ -17,7 +17,7 @@ import (
)
var (
newSmokeRoot = app.NewRootCommand
newSmokeRoot = app.NewSchemaSourceRootCommand
buildEffectiveSmokeRegistry = cli.BuildEffectiveCommandRegistry
bindEffectiveSmokeRegistry = cli.BindEffectiveCommandRegistry
buildSmokeRegistryData = buildSmokeRegistry
+5
View File
@@ -36,6 +36,11 @@ const (
// parallel. When empty, the platform default applies.
StorageDirEnv = "DWS_KEYCHAIN_DIR"
// TestNamespaceEnv isolates the Windows HKCU registry backend for tests.
// Production code must not set it. Other platforms already isolate secure
// storage through StorageDirEnv and ignore this value.
TestNamespaceEnv = "DWS_KEYCHAIN_TEST_NAMESPACE"
// DisableKeychainEnv opts the macOS implementation out of system
// Keychain access for the DEK, falling back to a file-based DEK
// (same scheme as Linux). Intended for sandboxed runtimes where
+8
View File
@@ -14,6 +14,7 @@
package keychain
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -25,8 +26,15 @@ func TestMain(m *testing.M) {
panic(err)
}
_ = os.Setenv(StorageDirEnv, dir)
_ = os.Setenv(TestNamespaceEnv, dir)
_ = os.Setenv(DisableKeychainEnv, "1")
code := m.Run()
if err := RemoveAuthTokenEntries(Service); err != nil {
fmt.Fprintf(os.Stderr, "internal/keychain test cleanup: %v\n", err)
if code == 0 {
code = 1
}
}
_ = os.RemoveAll(dir)
os.Exit(code)
}
+19 -4
View File
@@ -16,6 +16,7 @@
package keychain
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
@@ -47,9 +48,8 @@ const regRootPath = `Software\DwsCli\keychain`
// The Windows keychain backend keeps secrets in DPAPI-protected HKCU registry
// values rather than on disk, so this path is used only by the portable
// auth-bundle export/import (internal/auth) to colocate config. When the
// DWS_KEYCHAIN_DIR environment variable is set (used by tests for isolation),
// the storage root is taken from that env var instead; otherwise it defaults
// to %LocalAppData%\<service>.
// DWS_KEYCHAIN_DIR environment variable is set, the storage root is taken from
// that env var; otherwise it defaults to %LocalAppData%\<service>.
func StorageDir(service string) string {
if override := os.Getenv(StorageDirEnv); override != "" {
return filepath.Join(override, service)
@@ -66,7 +66,22 @@ func StorageDir(service string) string {
}
func registryPathForService(service string) string {
return regRootPath + `\` + safeRegistryComponent(service)
path := regRootPath + `\` + safeRegistryComponent(service)
namespace := strings.TrimSpace(os.Getenv(TestNamespaceEnv))
if namespace == "" {
return path
}
// Windows stores credentials in HKCU instead of DWS_KEYCHAIN_DIR. Tests set
// an explicit process namespace so concurrent package binaries cannot
// delete each other's credentials. Hash it to avoid leaking temp paths or
// introducing registry separators.
namespace = filepath.Clean(namespace)
if absolute, err := filepath.Abs(namespace); err == nil {
namespace = absolute
}
sum := sha256.Sum256([]byte(strings.ToLower(namespace)))
return fmt.Sprintf(`%s\test-%x`, path, sum[:16])
}
var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
@@ -17,12 +17,37 @@ package keychain
import (
"errors"
"strings"
"testing"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
func TestCrossPlatformCoverageRegistryPathForServiceHonorsTestNamespace(t *testing.T) {
t.Setenv(TestNamespaceEnv, "")
defaultPath := registryPathForService("service")
if defaultPath != regRootPath+`\service` {
t.Fatalf("default registry path = %q, want historical path %q", defaultPath, regRootPath+`\service`)
}
t.Setenv(TestNamespaceEnv, t.TempDir())
firstPath := registryPathForService("service")
t.Setenv(TestNamespaceEnv, t.TempDir())
secondPath := registryPathForService("service")
if firstPath == defaultPath || secondPath == defaultPath {
t.Fatalf("isolated registry paths = %q, %q; want paths distinct from %q", firstPath, secondPath, defaultPath)
}
if firstPath == secondPath {
t.Fatalf("isolated registry paths collide: %q", firstPath)
}
if !strings.HasPrefix(firstPath, defaultPath+`\test-`) {
t.Fatalf("isolated registry path = %q, want prefix %q", firstPath, defaultPath+`\test-`)
}
}
func TestDeleteRegistryValuePropagatesFailure(t *testing.T) {
originalDelete := registryDeleteValue
failure := errors.New("delete failed")
+77 -17
View File
@@ -14,7 +14,9 @@
package plugin
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
@@ -24,6 +26,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
)
const maxPluginCLIOverlayBytes = 4 << 20
// UserContext holds the minimal user identity fields injected into
// stdio plugin subprocesses via environment variables.
type UserContext struct {
@@ -111,23 +115,9 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
continue
}
overlay := mcptypes.CLIOverlay{}
if len(srv.CLI) > 0 {
if err := json.Unmarshal(srv.CLI, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLIOverlay",
"plugin", p.Manifest.Name,
"server", key,
"error", err,
)
}
}
// Ensure the overlay has an ID — fall back to server key.
if overlay.ID == "" {
overlay.ID = key
}
if overlay.Command == "" {
overlay.Command = key
overlay, ok := p.ResolveCLIOverlay(key)
if !ok {
continue
}
source := "plugin"
@@ -154,3 +144,73 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
}
return descriptors
}
// ResolveCLIOverlay resolves inline or external manifest CLI metadata exactly
// once. External files are opened relative to the plugin root with os.Root so
// absolute paths, parent traversal, and escaping symlinks fail closed.
func (p *Plugin) ResolveCLIOverlay(serverKey string) (mcptypes.CLIOverlay, bool) {
overlay := mcptypes.CLIOverlay{
ID: serverKey,
Command: serverKey,
}
server, ok := p.Manifest.MCPServers[serverKey]
if !ok || len(server.CLI) == 0 {
return overlay, true
}
data := []byte(strings.TrimSpace(string(server.CLI)))
if len(data) == 0 {
return overlay, true
}
if data[0] == '"' {
var relativePath string
if err := json.Unmarshal(data, &relativePath); err != nil ||
strings.TrimSpace(relativePath) == "" {
slog.Warn("plugin: invalid external CLI overlay path",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
root, err := os.OpenRoot(p.Root)
if err != nil {
slog.Warn("plugin: failed to open plugin root",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
defer root.Close()
file, err := root.Open(relativePath)
if err != nil {
slog.Warn("plugin: failed to open CLI overlay file",
"plugin", p.Manifest.Name, "server", serverKey,
"path", relativePath, "error", err)
return mcptypes.CLIOverlay{}, false
}
defer file.Close()
data, err = io.ReadAll(io.LimitReader(file, maxPluginCLIOverlayBytes+1))
if err != nil || len(data) > maxPluginCLIOverlayBytes {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "server", serverKey,
"path", relativePath, "error", err)
return mcptypes.CLIOverlay{}, false
}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
slog.Warn("plugin: CLI overlay contains trailing JSON",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
if strings.TrimSpace(overlay.ID) == "" {
overlay.ID = serverKey
}
if strings.TrimSpace(overlay.Command) == "" {
overlay.Command = serverKey
}
return overlay, true
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package plugin
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestResolveCLIOverlayDefaultsAndInlineErrors(t *testing.T) {
plugin := &Plugin{
Root: t.TempDir(),
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"empty": {CLI: nil},
"whitespace": {CLI: json.RawMessage(" \n\t")},
"fallback": {CLI: json.RawMessage(`{"id":" ","command":""}`)},
"malformed": {CLI: json.RawMessage(`{`)},
"unknown": {CLI: json.RawMessage(`{"id":"plugin","unknownField":true}`)},
"unknownFlag": {CLI: json.RawMessage(`{
"toolOverrides":{"tool":{"flags":{"value":{"unknownFlagField":true}}}}
}`)},
"trailing": {CLI: json.RawMessage(`{} {}`)},
"legacyTools": {CLI: json.RawMessage(`{
"tools":[{
"name":"tool","cliName":"leaf","title":"Title","description":"Description",
"isSensitive":true,"category":"read","hidden":true,
"flags":{"value":{"alias":"value-alias","shorthand":"v"}}
}]
}`)},
}},
}
for _, serverKey := range []string{"missing", "empty", "whitespace", "fallback"} {
t.Run(serverKey, func(t *testing.T) {
overlay, ok := plugin.ResolveCLIOverlay(serverKey)
if !ok || overlay.ID != serverKey || overlay.Command != serverKey {
t.Fatalf("ResolveCLIOverlay(%q) = (%#v, %v), want default overlay", serverKey, overlay, ok)
}
})
}
legacy, ok := plugin.ResolveCLIOverlay("legacyTools")
if !ok || len(legacy.Tools) != 1 || legacy.Tools[0].CLIName != "leaf" ||
legacy.Tools[0].Flags["value"].Alias != "value-alias" {
t.Fatalf("ResolveCLIOverlay(legacyTools) = (%#v, %v), want historical tool metadata", legacy, ok)
}
if overlay, ok := plugin.ResolveCLIOverlay("malformed"); ok {
t.Fatalf("ResolveCLIOverlay(malformed) = (%#v, true), want failure", overlay)
}
for _, serverKey := range []string{"unknown", "unknownFlag", "trailing"} {
if overlay, ok := plugin.ResolveCLIOverlay(serverKey); ok {
t.Fatalf("ResolveCLIOverlay(%s) = (%#v, true), want strict failure", serverKey, overlay)
}
}
}
func TestResolveCLIOverlayRejectsInvalidExternalFiles(t *testing.T) {
root := t.TempDir()
plugin := &Plugin{
Root: root,
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"external": {},
}},
}
server := plugin.Manifest.MCPServers["external"]
t.Run("malformed path JSON", func(t *testing.T) {
server.CLI = json.RawMessage(`"unterminated`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want malformed path failure", overlay)
}
})
t.Run("blank path", func(t *testing.T) {
server.CLI = json.RawMessage(`" "`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want blank path failure", overlay)
}
})
t.Run("missing root", func(t *testing.T) {
plugin.Root = filepath.Join(root, "does-not-exist")
server.CLI = json.RawMessage(`"overlay.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing root failure", overlay)
}
plugin.Root = root
})
t.Run("missing file", func(t *testing.T) {
server.CLI = json.RawMessage(`"missing.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing file failure", overlay)
}
})
t.Run("read error", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(root, "overlay-dir"), 0o700); err != nil {
t.Fatal(err)
}
server.CLI = json.RawMessage(`"overlay-dir"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want directory read failure", overlay)
}
})
t.Run("oversized file", func(t *testing.T) {
path := filepath.Join(root, "oversized.json")
if err := os.WriteFile(path, bytes.Repeat([]byte(" "), maxPluginCLIOverlayBytes+1), 0o600); err != nil {
t.Fatal(err)
}
server.CLI = json.RawMessage(`"oversized.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want oversized file failure", overlay)
}
})
}
func TestResolveCLIOverlayExternalFileAppliesFallbackIdentity(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "overlay.json"), []byte(`{
"id":"",
"command":" ",
"aliases":["conference"]
}`), 0o600); err != nil {
t.Fatal(err)
}
plugin := &Plugin{
Root: root,
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"server-key": {CLI: json.RawMessage(`"overlay.json"`)},
}},
}
overlay, ok := plugin.ResolveCLIOverlay("server-key")
if !ok || overlay.ID != "server-key" || overlay.Command != "server-key" ||
len(overlay.Aliases) != 1 || overlay.Aliases[0] != "conference" {
t.Fatalf("ResolveCLIOverlay() = (%#v, %v), want fallback identity with file metadata", overlay, ok)
}
}
+10 -6
View File
@@ -131,22 +131,19 @@ func TestCrossPlatformCoveragePluginConverterEdges(t *testing.T) {
t.Fatalf("StdioClients(user) length = %d", len(got))
}
descriptors := p.ToServerDescriptors()
if len(descriptors) != 2 {
if len(descriptors) != 1 {
t.Fatalf("descriptors length = %d", len(descriptors))
}
seenDefault := false
seenOverlay := false
for _, d := range descriptors {
switch d.Key {
case "http-default":
seenDefault = d.CLI.ID == "http-default" && d.CLI.Command == "http-default" && d.HasCLIMeta
case "http-overlay":
seenOverlay = d.CLI.ID == "custom" && d.CLI.Command == "run" &&
d.AuthHeaders["Authorization"] == "Bearer secret"
}
}
if !seenDefault || !seenOverlay {
t.Fatalf("descriptor defaults/overlay not covered: %#v", descriptors)
if !seenOverlay {
t.Fatalf("valid descriptor overlay not covered: %#v", descriptors)
}
dataDir := filepath.Join(filepath.Dir(filepath.Dir(root)), "data")
if got := expandPluginVars("$"+"{DWS_PLUGIN_ROOT}|$"+"{DWS_PLUGIN_DATA}|$"+"{PLUGIN_TOKEN}", root); got != root+"|"+dataDir+"|secret" {
@@ -300,6 +297,7 @@ func TestCrossPlatformCoverageManifestEdges(t *testing.T) {
func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
oldHome := pluginUserHomeDir
t.Cleanup(func() { pluginUserHomeDir = oldHome })
t.Setenv("DWS_CONFIG_DIR", "")
home := t.TempDir()
pluginUserHomeDir = func() (string, error) { return home, nil }
defaultLoader := NewLoader("1.2.3")
@@ -310,6 +308,12 @@ func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
if got := NewLoader("dev").PluginsDir; got != filepath.Join(".dws", "plugins") {
t.Fatalf("NewLoader error path = %q", got)
}
customConfig := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", customConfig)
if got := NewLoader("dev").PluginsDir; got != filepath.Join(customConfig, "plugins") {
t.Fatalf("NewLoader custom config path = %q", got)
}
t.Setenv("DWS_CONFIG_DIR", "")
root := t.TempDir()
l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+6 -2
View File
@@ -56,9 +56,13 @@ var (
// NewLoader creates a Loader with default paths.
func NewLoader(cliVersion string) *Loader {
home, _ := pluginUserHomeDir()
configDir := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR"))
if configDir == "" {
home, _ := pluginUserHomeDir()
configDir = filepath.Join(home, ".dws")
}
return &Loader{
PluginsDir: filepath.Join(home, ".dws", "plugins"),
PluginsDir: filepath.Join(configDir, "plugins"),
CLIVersion: cliVersion,
}
}
+77 -2
View File
@@ -74,6 +74,56 @@ func TestParseManifest(t *testing.T) {
}
}
func TestResolveCLIOverlayExternalFileIsRootContained(t *testing.T) {
root := t.TempDir()
overlayPath := filepath.Join(root, "overlay.json")
if err := os.WriteFile(overlayPath, []byte(`{
"id":"external-id",
"command":"external-command",
"toolOverrides":{"ping":{"cliName":"ping"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
loaded := &Plugin{
Root: root,
Manifest: Manifest{
Name: "external-plugin",
MCPServers: map[string]*MCPServer{
"external": {
Type: "streamable-http",
Endpoint: "https://example.invalid/mcp",
CLI: json.RawMessage(`"overlay.json"`),
},
},
},
}
overlay, ok := loaded.ResolveCLIOverlay("external")
if !ok || overlay.ID != "external-id" || overlay.Command != "external-command" {
t.Fatalf("external overlay = (%#v, %v)", overlay, ok)
}
descriptors := loaded.ToServerDescriptors()
if len(descriptors) != 1 || descriptors[0].CLI.ID != "external-id" {
t.Fatalf("external HTTP descriptors = %#v", descriptors)
}
outside := filepath.Join(t.TempDir(), "outside-overlay.json")
if err := os.WriteFile(outside, []byte(`{"id":"escaped"}`), 0o600); err != nil {
t.Fatal(err)
}
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"../outside-overlay.json"`)
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
t.Fatalf("parent traversal resolved overlay %#v", overlay)
}
link := filepath.Join(root, "escaped-link.json")
if err := os.Symlink(outside, link); err == nil {
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"escaped-link.json"`)
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
t.Fatalf("escaping symlink resolved overlay %#v", overlay)
}
}
}
func TestManifestValidate(t *testing.T) {
tests := []struct {
name string
@@ -219,8 +269,23 @@ func TestLoadAllSuppressesOptionalPluginValidationWarnings(t *testing.T) {
func TestPluginToServerDescriptors(t *testing.T) {
cliOverlay, _ := json.Marshal(map[string]any{
"id": "conference",
"command": "conference",
"id": "conference",
"command": "conference",
"description": "video conference",
"groups": map[string]any{
"camera": map[string]any{"description": "camera control"},
},
"toolOverrides": map[string]any{
"open_camera": map[string]any{
"cliName": "open",
"group": "camera",
"description": "open camera",
"isSensitive": true,
"flags": map[string]any{
"device_id": map[string]any{"description": "camera device"},
},
},
},
})
p := &Plugin{
@@ -262,6 +327,16 @@ func TestPluginToServerDescriptors(t *testing.T) {
if d.CLI.ID != "conference" {
t.Errorf("cli.id = %q, want conference", d.CLI.ID)
}
override := d.CLI.ToolOverrides["open_camera"]
if d.CLI.Description != "video conference" ||
d.CLI.Groups["camera"].Description != "camera control" ||
override.CLIName != "open" ||
override.Group != "camera" ||
override.Description != "open camera" ||
!override.IsSensitive ||
override.Flags["device_id"].Description != "camera device" {
t.Fatalf("CLI overlay command metadata was not preserved: %#v", d.CLI)
}
}
func TestPluginToServerDescriptorsWithHeaders(t *testing.T) {
@@ -0,0 +1,33 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package builtin_test
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
)
func TestBaseCommandsExposeSortedDistributionShortcuts(t *testing.T) {
commands := builtin.BaseCommands()
if len(commands) == 0 {
t.Fatal("BaseCommands returned no distribution shortcuts")
}
for index, command := range commands {
if index > 0 && commands[index-1].Name() > command.Name() {
t.Fatalf("BaseCommands are not sorted: %q before %q", commands[index-1].Name(), command.Name())
}
children := command.Commands()
if len(children) == 0 {
t.Fatalf("base shortcut service %q has no commands", command.Name())
}
for _, child := range children {
if !strings.HasPrefix(child.Name(), "+") {
t.Fatalf("base shortcut %q under %q is not mounted as a +command", child.Name(), command.Name())
}
}
}
}
+6
View File
@@ -50,3 +50,9 @@ import (
func Commands() []*cobra.Command {
return shortcut.Commands()
}
// BaseCommands returns only distribution-owned shortcuts for Schema and
// interface generation.
func BaseCommands() []*cobra.Command {
return shortcut.BuiltInCommands()
}
+11
View File
@@ -41,6 +41,17 @@ func Commands() []*cobra.Command {
return build(allShortcuts)
}
// BuiltInCommands compiles only distribution-owned shortcuts.
func BuiltInCommands() []*cobra.Command {
builtins := make([]Shortcut, 0, len(allShortcuts))
for _, registered := range allShortcuts {
if !registered.UserDefined {
builtins = append(builtins, registered)
}
}
return build(builtins)
}
// All returns the registered shortcuts. Primarily for coverage tests that need
// each shortcut's declared flags (types/enums/required) to synthesize inputs.
func All() []Shortcut {

Some files were not shown because too many files have changed in this diff Show More