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
修雨 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
64 changed files with 5010 additions and 32457 deletions
+22 -7
View File
@@ -1694,13 +1694,28 @@ jobs:
run: |
set -eu
. ./scripts/release/release-lib.sh
delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")"
if [ "$delivered" = "$SEMVER" ]; then exit 0; fi
release_version_is_greater "v$delivered" "v$SEMVER" || {
echo "npm $NPM_TAG=$delivered does not deliver or supersede v$SEMVER" >&2
exit 1
}
echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered."
attempt=1
max_attempts=12
while :; do
delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG" \
--registry=https://registry.npmjs.org --prefer-online)"
if [ "$delivered" = "$SEMVER" ]; then exit 0; fi
if release_version_is_greater "v$delivered" "v$SEMVER"; then
echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered."
exit 0
fi
release_version_is_greater "v$SEMVER" "v$delivered" || {
echo "npm $NPM_TAG=$delivered cannot be reconciled with v$SEMVER" >&2
exit 1
}
if [ "$attempt" -ge "$max_attempts" ]; then
echo "npm $NPM_TAG still reports older v$delivered after $attempt attempts; expected v$SEMVER" >&2
exit 1
fi
echo "npm $NPM_TAG still reports older v$delivered; waiting for registry propagation ($attempt/$max_attempts)."
sleep 5
attempt=$((attempt + 1))
done
- name: Sync release artifacts to China OSS mirror
if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }}
+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
+37
View File
@@ -6,6 +6,43 @@ 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.
+4 -4
View File
@@ -69,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 一起发布。
## 预发发布
@@ -131,7 +131,7 @@ 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、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 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;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
- 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 漂移产生不同字节。
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
+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) {
+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)
}
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
}
+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 {
+37 -3
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)
@@ -485,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
@@ -766,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,
@@ -778,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
@@ -798,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(
+20
View File
@@ -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,6 +337,16 @@ 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, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
+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)
}
+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
}
+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
+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 {
+20
View File
@@ -266,6 +266,26 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) {
}
}
func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) {
previous := append([]Shortcut(nil), allShortcuts...)
t.Cleanup(func() { allShortcuts = previous })
allShortcuts = nil
Register(
Shortcut{Service: "calendar", Command: "+builtin", Execute: noop},
Shortcut{Service: "calendar", Command: "+user", UserDefined: true, Execute: noop},
)
all := Commands()
if len(all) != 1 || len(all[0].Commands()) != 2 {
t.Fatalf("all shortcut commands = %#v", all)
}
builtins := BuiltInCommands()
if len(builtins) != 1 || len(builtins[0].Commands()) != 1 ||
builtins[0].Commands()[0].Name() != "+builtin" {
t.Fatalf("built-in shortcut commands = %#v", builtins)
}
}
func noop(_ *RuntimeContext) error { return nil }
func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
+4
View File
@@ -127,6 +127,10 @@ type Shortcut struct {
Tips []string
// Hidden hides the command from listings while keeping it invocable.
Hidden bool
// UserDefined identifies shortcuts loaded from the user's config
// directory. Distribution-owned Schema and interface snapshots exclude
// these runtime extensions even if another root loaded them earlier.
UserDefined bool
// Validate optionally checks resolved flag values before execution. Return a
// non-nil error to abort with a validation message. Runs after built-in
+1
View File
@@ -196,6 +196,7 @@ func Compile(s Spec) shortcut.Shortcut {
Intent: intent,
Risk: risk,
Flags: flags,
UserDefined: true,
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
for key, tmpl := range bind {
+3
View File
@@ -69,6 +69,9 @@ func TestCrossPlatformCoverageCompileFlagsAndDefaults(t *testing.T) {
if sc.Service != "chat" || sc.Command != "+notify-team" {
t.Fatalf("bad identity: %+v", sc)
}
if !sc.UserDefined {
t.Fatal("compiled user shortcut is missing user-defined provenance")
}
if sc.Risk != shortcut.RiskRead {
t.Errorf("risk default = %q, want read", sc.Risk)
}
+19 -1
View File
@@ -23,15 +23,28 @@ const SourceAnnotation = "dws.source"
// SourceEnvelope marks a command as authored by the runtime discovery envelope.
const SourceEnvelope = "envelope"
// SourcePlugin marks a command as an installed plugin extension. Plugin
// commands are part of the runtime CLI surface, not the embedded base Schema.
const SourcePlugin = "plugin"
// MarkEnvelopeSource stamps cmd with runtime discovery provenance.
func MarkEnvelopeSource(cmd *cobra.Command) {
markSource(cmd, SourceEnvelope)
}
// MarkPluginSource stamps cmd with installed-plugin provenance.
func MarkPluginSource(cmd *cobra.Command) {
markSource(cmd, SourcePlugin)
}
func markSource(cmd *cobra.Command, source string) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[SourceAnnotation] = SourceEnvelope
cmd.Annotations[SourceAnnotation] = source
}
// IsEnvelopeSourced reports whether cmd was authored by the runtime discovery
@@ -40,6 +53,11 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// IsPluginSourced reports whether cmd came from an installed plugin.
func IsPluginSourced(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourcePlugin
}
// KindAnnotation is the annotation key for marking command kinds.
const KindAnnotation = "dws.kind"
+16
View File
@@ -43,3 +43,19 @@ func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) {
t.Parallel()
MarkEnvelopeSource(nil)
}
func TestPluginSourceProvenance(t *testing.T) {
t.Parallel()
if IsPluginSourced(nil) {
t.Fatal("nil command should not be plugin sourced")
}
cmd := &cobra.Command{Use: "conference"}
MarkPluginSource(cmd)
if !IsPluginSourced(cmd) || IsEnvelopeSourced(cmd) {
t.Fatalf("plugin source annotation = %#v", cmd.Annotations)
}
if got := cmd.Annotations[SourceAnnotation]; got != SourcePlugin {
t.Fatalf("SourceAnnotation = %q, want %q", got, SourcePlugin)
}
MarkPluginSource(nil)
}
+98 -3
View File
@@ -1,6 +1,10 @@
package mcptypes
import "encoding/json"
import (
"bytes"
"encoding/json"
"strings"
)
type ServerDescriptor struct {
Key string
@@ -16,19 +20,110 @@ type ServerDescriptor struct {
type CLIOverlay struct {
ID string `json:"id"`
Command string `json:"command"`
Parent string `json:"parent,omitempty"`
Description string `json:"description,omitempty"`
Aliases []string `json:"aliases"`
Prefixes []string `json:"prefixes"`
Group string `json:"group,omitempty"`
Skip bool `json:"skip"`
Hidden bool `json:"hidden,omitempty"`
Tools []CLITool `json:"tools"`
Groups map[string]CLIGroupDef `json:"groups,omitempty"`
ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"`
ServerDeps []string `json:"serverDeps,omitempty"`
Hints map[string]json.RawMessage `json:"hintCommands,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
}
type CLIGroupDef struct {
Description string `json:"description,omitempty"`
}
type CLITool struct {
Name string `json:"name"`
Name string `json:"name"`
CLIName string `json:"cliName,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
IsSensitive bool `json:"isSensitive,omitempty"`
Category string `json:"category,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Flags map[string]CLIFlagHint `json:"flags,omitempty"`
}
type CLIFlagHint struct {
Shorthand string `json:"shorthand,omitempty"`
Alias string `json:"alias,omitempty"`
}
type CLIToolOverride struct {
ServerOverride string `json:"serverOverride,omitempty"`
CLIName string `json:"cliName,omitempty"`
CLIAliases []string `json:"cliAliases,omitempty"`
Description string `json:"description,omitempty"`
Example string `json:"example,omitempty"`
Group string `json:"group,omitempty"`
IsSensitive bool `json:"isSensitive,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
OutputFormat map[string]any `json:"outputFormat,omitempty"`
ServerOverride string `json:"serverOverride,omitempty"`
BodyWrapper string `json:"bodyWrapper,omitempty"`
MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"`
RequireOneOf [][]string `json:"requireOneOf,omitempty"`
RequireTogether [][]string `json:"requireTogether,omitempty"`
RejectPositional bool `json:"rejectPositional,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
Pipeline []json.RawMessage `json:"pipeline,omitempty"`
}
type CLIFlagOverride struct {
Alias string `json:"alias,omitempty"`
Aliases []string `json:"aliases,omitempty"`
MapsTo string `json:"mapsTo,omitempty"`
Transform string `json:"transform,omitempty"`
TransformArgs map[string]any `json:"transformArgs,omitempty"`
EnvDefault string `json:"envDefault,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Default string `json:"default,omitempty"`
Shorthand string `json:"shorthand,omitempty"`
Required bool `json:"required,omitempty"`
Description string `json:"description,omitempty"`
Positional bool `json:"positional,omitempty"`
PositionalIndex int `json:"positionalIndex,omitempty"`
Type string `json:"type,omitempty"`
OmitWhen string `json:"omitWhen,omitempty"`
RuntimeDefault string `json:"runtimeDefault,omitempty"`
PipelineLocal bool `json:"pipelineLocal,omitempty"`
}
func (override *CLIFlagOverride) UnmarshalJSON(data []byte) error {
type alias CLIFlagOverride
aux := struct {
Default json.RawMessage `json:"default,omitempty"`
*alias
}{alias: (*alias)(override)}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&aux); err != nil {
return err
}
override.Default = coercePluginScalar(aux.Default)
return nil
}
func coercePluginScalar(raw json.RawMessage) string {
value := strings.TrimSpace(string(raw))
if value == "" || value == "null" ||
strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") {
return ""
}
if strings.HasPrefix(value, `"`) {
var decoded string
if json.Unmarshal(raw, &decoded) == nil {
return decoded
}
return ""
}
return value
}
func OverlayFromJSON(data json.RawMessage) CLIOverlay {
+70
View File
@@ -0,0 +1,70 @@
package mcptypes
import (
"encoding/json"
"testing"
)
func TestCLIFlagOverrideUnmarshalJSONCoercesScalarDefaults(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "missing", raw: `{}`, want: ""},
{name: "null", raw: `{"default":null}`, want: ""},
{name: "object", raw: `{"default":{"nested":true}}`, want: ""},
{name: "array", raw: `{"default":[1,2]}`, want: ""},
{name: "string", raw: `{"default":"hello"}`, want: "hello"},
{name: "escaped string", raw: `{"default":"line\nvalue"}`, want: "line\nvalue"},
{name: "boolean", raw: `{"default":true}`, want: "true"},
{name: "integer", raw: `{"default":42}`, want: "42"},
{name: "number", raw: `{"default":-1.25}`, want: "-1.25"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got CLIFlagOverride
if err := json.Unmarshal([]byte(tt.raw), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Default != tt.want {
t.Fatalf("Default = %q, want %q", got.Default, tt.want)
}
})
}
var got CLIFlagOverride
if err := json.Unmarshal([]byte(`{"alias":`), &got); err == nil {
t.Fatal("json.Unmarshal() error = nil, want malformed JSON error")
}
if err := got.UnmarshalJSON([]byte(`{"alias":`)); err == nil {
t.Fatal("CLIFlagOverride.UnmarshalJSON() error = nil, want malformed JSON error")
}
if err := json.Unmarshal([]byte(`{"unknownFlagField":true}`), &got); err == nil {
t.Fatal("CLIFlagOverride.UnmarshalJSON() accepted an unknown field")
}
if got := coercePluginScalar(json.RawMessage(`"unterminated`)); got != "" {
t.Fatalf("coercePluginScalar(invalid string) = %q, want empty", got)
}
}
func TestOverlayFromJSONHandlesEmptyValidAndMalformedInput(t *testing.T) {
if got := OverlayFromJSON(nil); got.ID != "" || got.Command != "" {
t.Fatalf("OverlayFromJSON(nil) = %#v, want zero overlay", got)
}
valid := json.RawMessage(`{
"id":"conference",
"command":"meeting",
"toolOverrides":{"create":{"flags":{"count":{"default":3}}}}
}`)
got := OverlayFromJSON(valid)
if got.ID != "conference" || got.Command != "meeting" ||
got.ToolOverrides["create"].Flags["count"].Default != "3" {
t.Fatalf("OverlayFromJSON(valid) = %#v", got)
}
if got := OverlayFromJSON(json.RawMessage(`{`)); got.ID != "" || got.Command != "" {
t.Fatalf("OverlayFromJSON(malformed) = %#v, want zero overlay", got)
}
}
+9 -6
View File
@@ -128,13 +128,8 @@ require_remote() {
}
sync_main_if_safe() {
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "main" ] || {
printf 'release validation must run from the main worktree (current: %s)\n' "${current_branch:-detached HEAD}" >&2
exit 1
}
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf '%s\n' 'release main worktree must be clean before synchronization' >&2
printf '%s\n' 'release worktree must be clean before synchronization' >&2
exit 1
}
@@ -146,6 +141,14 @@ sync_main_if_safe() {
if [ "$head_commit" = "$remote_commit" ]; then
return 0
fi
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [ "$current_branch" != "main" ]; then
if git merge-base --is-ancestor HEAD "$remote_main"; then
return 0
fi
printf 'HEAD is not contained in %s/main history; merge it through a reviewed PR before release\n' "$REMOTE" >&2
exit 1
fi
if git merge-base --is-ancestor HEAD "$remote_main"; then
git merge --ff-only "$remote_main"
return 0
+2 -13
View File
@@ -71,20 +71,14 @@ git rev-parse --verify --quiet "$remote_main^{commit}" >/dev/null || {
printf 'release branch is not available locally: %s/%s\n' "$REMOTE" "$BRANCH" >&2
exit 1
}
remote_main_commit="$(git rev-parse "$remote_main^{commit}")"
if [ "$CONTEXT" = "local" ]; then
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf 'release worktree must be clean (staged, unstaged, and untracked files are blocked)\n' >&2
exit 1
}
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "$BRANCH" ] || {
printf 'local release must run from branch %s (current: %s)\n' "$BRANCH" "${current_branch:-detached HEAD}" >&2
exit 1
}
[ "$head_commit" = "$remote_main_commit" ] || {
printf 'HEAD must exactly match %s/%s before release\n' "$REMOTE" "$BRANCH" >&2
git merge-base --is-ancestor HEAD "$remote_main" || {
printf 'HEAD must be contained in %s/%s history before release\n' "$REMOTE" "$BRANCH" >&2
exit 1
}
if git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then
@@ -227,11 +221,6 @@ else
printf 'stable beta baseline is not an ancestor of HEAD: %s\n' "$FROM_BETA" >&2
exit 1
}
if ! git diff --quiet "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md'; then
printf 'stable source drifted from %s; only CHANGELOG.md may differ\n' "$FROM_BETA" >&2
git diff --name-only "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md' >&2
exit 1
fi
fi
semver="$(release_semver "$VERSION")"
+6 -7
View File
@@ -351,7 +351,7 @@ else
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--base-ref HEAD \
--stable-ref "$previous_stable"
fi
@@ -405,7 +405,7 @@ if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
"${previous_stable_before_refresh:-none}" "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--base-ref HEAD \
--stable-ref "$previous_stable"
fi
@@ -416,9 +416,9 @@ fi
# Delivery, compatibility, and publication checks above may take long enough
# for main or stable authority to move. This last refresh must be followed only
# by local proof/tag creation. The atomic push advertises main with the tag, so
# an already-advanced remote main rejects the whole transaction; a later main
# advance is safe because the sealed commit remains in protected main history.
# by local proof/tag creation. Only the tag is pushed: the sealed commit is
# already contained in protected main history, so a later main advance never
# invalidates the release.
printf '==> Settling final %s/%s and stable authority\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
@@ -447,8 +447,7 @@ else
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
fi
if ! git push --atomic "$push_url" \
"HEAD:refs/heads/$BRANCH" "refs/tags/$VERSION"; then
if ! git push "$push_url" "refs/tags/$VERSION"; then
set +e
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
query_status=$?
+4 -6
View File
@@ -53,8 +53,6 @@ while [ "$#" -gt 0 ]; do
esac
done
[ -z "$EXPECTED_VERSION" ] || need_cmd strings
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
HOME_AGENT_PARENTS="
.claude
@@ -145,8 +143,8 @@ verify_npm() {
if [ -n "$EXPECTED_VERSION" ]; then
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
need_file "$vendor_bin"
strings "$vendor_bin" | grep -Fqx "v$EXPECTED_VERSION" || \
err "npm-installed binary does not embed expected version v$EXPECTED_VERSION"
LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$vendor_bin" || \
err "npm-installed binary does not contain expected version marker v$EXPECTED_VERSION"
EXPECTED_VERSION="$EXPECTED_VERSION" node -e '
const pkg = require(process.argv[1]);
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
@@ -193,8 +191,8 @@ verify_brew() {
[ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws"
"$prefix/bin/dws" --help >/dev/null
if [ -n "$EXPECTED_VERSION" ]; then
strings "$prefix/bin/dws" | grep -Fqx "v$EXPECTED_VERSION" || \
err "Homebrew-installed binary does not embed expected version v$EXPECTED_VERSION"
LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$prefix/bin/dws" || \
err "Homebrew-installed binary does not contain expected version marker v$EXPECTED_VERSION"
fi
need_file "$prefix/share/dingtalk-workspace-cli-local/skills/dws/SKILL.md"
}
+1 -1
View File
@@ -94,7 +94,7 @@ verify_binary_version() {
printf '%s does not contain the expected dws binary\n' "$asset" >&2
return 1
}
strings "$binary" | grep -Fqx "v$SEMVER" || {
LC_ALL=C grep -aFq "v$SEMVER" "$binary" || {
printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2
return 1
}
-479
View File
@@ -1,479 +0,0 @@
#!/usr/bin/env python3
"""Run every read shortcut against the real DWS backend.
This launches the built CLI once per read shortcut. It does not use --mock and
does not use --dry-run. Inputs are synthetic but realistic where a shortcut
expects a name, date, or query; resource identifiers remain test placeholders
when no resource has been created for that command.
"""
from __future__ import annotations
import datetime as dt
import argparse
import json
import os
import shlex
import subprocess
import time
from pathlib import Path
from shortcut_real_result import (
classify_failure,
sanitize_result,
summarize_failure_categories,
classify_real_status,
summarize_results,
)
ROOT = Path(__file__).resolve().parents[1]
MATRIX_PATH = Path("/private/tmp/dws-shortcut-matrix.json")
OUT_PATH = ROOT / "docs" / "shortcut-real-read-results.json"
BIN = os.environ.get("DWS_REAL_TEST_BIN", "/private/tmp/dws-real-test")
DEVAPP_FIXTURE_ID = "678f27ec-4339-49d8-9c49-371b284bf552"
DEVAPP_FIXTURE_VERSION_ID = "4743accb-45e2-4bc9-8c96-74fc62ace2e8"
CHAT_FIXTURE_OPEN_CONVERSATION_ID = "cid3Jijzhe2aqs9ysOXjhi05g=="
CHAT_FIXTURE_GROUP_NAME = "浅曦-kida,Dennis,秋画"
CHAT_FIXTURE_DM_OPEN_CONVERSATION_ID = "cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI="
CHAT_FIXTURE_OPEN_MESSAGE_ID = "msgEuOor1PmFBNlx9M06N9z1Q=="
CHAT_FIXTURE_OPEN_TASK_ID = "y/wM6Lo+9GbIqtILYPv1BZDcMW+2FgnqskgcpdOiMdM="
CALENDAR_FIXTURE_EVENT_ID = "THN4YUtOTlplYU9sZzd2czE4YURLQT09_1784078100000"
DOC_FIXTURE_NODE_ID = "P0MALyR8knpgo9GycY7ZlMxlJ3bzYmDO"
DOC_FIXTURE_FOLDER_ID = "Amq4vjg89ZOAdqyaSMKpApXdW3kdP0wQ"
DOC_FIXTURE_EXPORT_JOB_ID = "29346731713"
SHEET_FIXTURE_NODE_ID = "mweZ92PV6O36dZbnsMZx70ylJxEKBD6p"
DING_FIXTURE_OPEN_DING_ID = "5D73E3AC29C780072D1CD56C6874ACC2"
CONTACT_FIXTURE_MOBILE = "13161187007"
AITABLE_FIXTURE_BASE_ID = "gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk"
AITABLE_FIXTURE_TABLE_ID = "hERWDMS"
AITABLE_FIXTURE_VIEW_ID = "qvGDAH2"
AITABLE_FIXTURE_FORM_VIEW_ID = "lmeV1cb"
AITABLE_FIXTURE_FIELD_ID = "01ZM8y7"
AITABLE_FIXTURE_RECORD_ID = "1015oH3OXy"
AITABLE_FIXTURE_DASHBOARD_ID = "KY9tlWg5NEHgfs8WT6IO2"
AITABLE_FIXTURE_CHART_ID = "widget-dlxFo0tNSImp4ITpDn5fQ"
HELD_CASES = {
("devapp", "+credentials-get"):
"该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。",
}
def ensure_matrix() -> dict:
env = os.environ.copy()
env.setdefault("GOCACHE", "/private/tmp/dws_gocache")
raw = subprocess.check_output(
["go", "run", "./scripts/gen_shortcut_test_matrix.go"],
cwd=ROOT,
env=env,
text=True,
)
MATRIX_PATH.write_text(raw, encoding="utf-8")
return json.loads(raw)
def replace_flag_values(args: list[str], service: str, command: str) -> list[str]:
today = dt.date.today()
start = (dt.datetime.now() + dt.timedelta(days=1)).replace(hour=10, minute=0, second=0, microsecond=0).isoformat() + "+08:00"
end = (dt.datetime.now() + dt.timedelta(days=1)).replace(hour=11, minute=0, second=0, microsecond=0).isoformat() + "+08:00"
no_id = "DWSREALREADNOSUCHID0000000000000"
no_conv = "cidDWSREALREADNOSUCHCONV"
day = str(today)
yesterday = str(today - dt.timedelta(days=1))
datetime_start = dt.datetime.now().replace(hour=10, minute=0, second=0, microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
datetime_end = (dt.datetime.now() + dt.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
replacements = {
"name": "DWS shortcut 真实测试",
"query": "测试",
"keyword": "测试",
"q": "测试",
"text": "测试",
"title": "测试",
"phone": "13000000000",
"mobile": "13000000000",
"user": "冬翔",
"users": "冬翔",
"to": "冬翔",
"with": "冬翔",
"who": "冬翔",
"dept": "模型算法",
"dept-id": "842379556",
"department-id": "842379556",
"start": start,
"end": end,
"from": str(today - dt.timedelta(days=7)),
"to-date": str(today),
"date": str(today),
"time": datetime_start,
"days": "7",
"types": "leave",
"columns": "1001",
"limit": "10",
"page": "1",
"page-size": "10",
"cursor": "0",
"calendar-id": "primary",
"event": no_id,
"type": "ALL",
"types": "leave",
"columns": "1001",
"role-types": "executor",
"status": "false",
"artifacts": "basic",
"direction": "older",
"file-types": "alidoc",
"order-by": "name",
"order": "asc",
"space-id": "1",
"space": "测试",
"base": no_id,
"base-id": no_id,
"table": no_id,
"table-id": no_id,
"view-id": no_id,
"record-id": no_id,
"record-ids": no_id,
"field-id": no_id,
"dashboard-id": no_id,
"chart-id": no_id,
"workflow-id": no_id,
"node": no_id,
"doc": no_id,
"folder": no_id,
"workspace": no_id,
"group": no_conv,
"conversation-id": no_conv,
"open-conversation-id": no_conv,
"message-id": no_id,
"msg-id": no_id,
"id": no_id,
"session-id": no_id,
"process-instance-id": no_id,
"task-id": no_id,
"template-id": no_id,
"mail-id": no_id,
"filters": "{}",
"sort": "[]",
}
if service == "calendar" and command == "+free-slots":
replacements["from"] = "9"
replacements["to"] = "18"
if service == "calendar":
replacements["event"] = CALENDAR_FIXTURE_EVENT_ID
replacements["cursor"] = ""
if command == "+freebusy":
replacements["users"] = "103262"
if service == "doc" and command == "+comment-list":
replacements["type"] = "global"
if service == "doc":
replacements["node"] = DOC_FIXTURE_NODE_ID
replacements["doc"] = DOC_FIXTURE_NODE_ID
replacements["folder"] = DOC_FIXTURE_FOLDER_ID
replacements["job-id"] = DOC_FIXTURE_EXPORT_JOB_ID
if service == "drive":
replacements["node"] = DOC_FIXTURE_NODE_ID
replacements["folder"] = DOC_FIXTURE_FOLDER_ID
if service == "todo":
replacements["task-id"] = "55119034912"
replacements["size"] = "10"
if service == "aitable":
replacements["name"] = "Real共创版设备去向登记"
replacements["base"] = AITABLE_FIXTURE_BASE_ID
replacements["base-id"] = AITABLE_FIXTURE_BASE_ID
replacements["table"] = AITABLE_FIXTURE_TABLE_ID
replacements["table-id"] = AITABLE_FIXTURE_TABLE_ID
replacements["view-id"] = AITABLE_FIXTURE_VIEW_ID
replacements["view-ids"] = AITABLE_FIXTURE_VIEW_ID
replacements["field-id"] = AITABLE_FIXTURE_FIELD_ID
replacements["field-ids"] = AITABLE_FIXTURE_FIELD_ID
replacements["record-id"] = AITABLE_FIXTURE_RECORD_ID
replacements["record-ids"] = AITABLE_FIXTURE_RECORD_ID
replacements["dashboard-id"] = AITABLE_FIXTURE_DASHBOARD_ID
replacements["chart-id"] = AITABLE_FIXTURE_CHART_ID
if command.startswith("+form-"):
replacements["view-id"] = AITABLE_FIXTURE_FORM_VIEW_ID
if command == "+resolve-table":
replacements["name"] = "Mac Mini"
if service == "ding" and command == "+list":
replacements["type"] = "ALL"
if service == "ding" and command == "+receiver-status":
replacements["ding-id"] = DING_FIXTURE_OPEN_DING_ID
if service == "contact" and command == "+list-sub-depts":
replacements["dept"] = "842379556"
if service == "contact":
replacements["name"] = "冬翔"
if command == "+by-mobile":
replacements["mobile"] = CONTACT_FIXTURE_MOBILE
if command == "+resolve-dept":
replacements["name"] = "模型算法"
if service == "oa":
now_ms = int(dt.datetime.now().timestamp() * 1000)
replacements["start"] = str(now_ms - 7 * 24 * 60 * 60 * 1000)
replacements["end"] = str(now_ms)
replacements["page"] = "1"
replacements["limit"] = "10"
if service == "report":
replacements["start"] = (dt.datetime.now() - dt.timedelta(days=7)).replace(microsecond=0).isoformat() + "+08:00"
replacements["end"] = dt.datetime.now().replace(microsecond=0).isoformat() + "+08:00"
replacements["modified-start"] = replacements["start"]
replacements["modified-end"] = replacements["end"]
if service == "attendance":
replacements.update({
"user": "202397",
"users": "202397",
"staff-ids": "202397",
"operator-staff-id": "202397",
"leave-code": "731ed089-62ff-4734-a6c7-3c8fcc8294fc",
"leave-names": "年假",
"start": yesterday,
"end": day,
"from": yesterday,
"to-date": day,
"date": day,
})
if command in {"+get-checkin-record", "+query-report-data", "+query-report-leave"}:
replacements["start"] = datetime_start
replacements["end"] = datetime_end
if command == "+get-approve-template":
replacements["type"] = "leave"
if command == "+search-group":
replacements["type"] = "FIXED"
if service == "devapp":
replacements["unified-app-id"] = DEVAPP_FIXTURE_ID
replacements["version-id"] = DEVAPP_FIXTURE_VERSION_ID
replacements["cursor"] = ""
if service == "mail":
replacements["email"] = "xinyang.dxy@alibaba-inc.com"
replacements["folder"] = "2"
replacements["query"] = "subject:测试"
replacements["keyword"] = "董鑫阳"
replacements["employee-no"] = "202397"
replacements["size"] = "10"
replacements["cursor"] = ""
if command == "+find-mail-user":
replacements["query"] = "董鑫阳"
if service == "chat":
replacements["group"] = CHAT_FIXTURE_OPEN_CONVERSATION_ID
replacements["conversation-id"] = CHAT_FIXTURE_OPEN_CONVERSATION_ID
replacements["open-conversation-id"] = CHAT_FIXTURE_OPEN_CONVERSATION_ID
replacements["msg-ids"] = CHAT_FIXTURE_OPEN_MESSAGE_ID
replacements["message-id"] = CHAT_FIXTURE_OPEN_MESSAGE_ID
replacements["msg-id"] = CHAT_FIXTURE_OPEN_MESSAGE_ID
replacements["open-task-id"] = CHAT_FIXTURE_OPEN_TASK_ID
if command == "+group-members":
replacements["group"] = CHAT_FIXTURE_GROUP_NAME
if command == "+messages-read-status":
replacements["conversation-id"] = CHAT_FIXTURE_DM_OPEN_CONVERSATION_ID
replacements["users"] = "冬翔"
if command == "+messages-resource-url":
replacements["type"] = "mediaId"
if command == "+bot-find":
replacements["cursor"] = ""
if service == "sheet" and command == "+list-sheets":
replacements["node"] = SHEET_FIXTURE_NODE_ID
out = list(args)
if "--format" not in out:
out.extend(["--format", "json"])
i = 0
while i < len(out) - 1:
if out[i].startswith("--"):
key = out[i][2:]
if key in replacements and not out[i + 1].startswith("--"):
out[i + 1] = replacements[key]
i += 2
continue
i += 1
return out
def shell_join(cmd: list[str]) -> str:
return " ".join(shlex.quote(x) for x in cmd)
def drop_flag(args: list[str], flag: str) -> list[str]:
out: list[str] = []
i = 0
while i < len(args):
if args[i] == flag:
i += 2 if i + 1 < len(args) and not args[i + 1].startswith("--") else 1
continue
out.append(args[i])
i += 1
return out
def adjust_command_args(args: list[str], service: str, command: str) -> list[str]:
if service == "chat" and command == "+chat-messages":
return drop_flag(args, "--user")
if service == "chat" and command == "+messages-list-direct":
out = drop_flag(args, "--open-dingtalk-id")
for i in range(len(out) - 1):
if out[i] == "--user":
out[i + 1] = "103262"
return out
if service == "calendar" and command == "+freebusy":
return drop_flag(args, "--rooms")
if service == "devapp" and command == "+permission-list":
out = drop_flag(args, "--scope-value")
out = drop_flag(out, "--scope-type")
out = drop_flag(out, "--api-status")
for i in range(len(out) - 1):
if out[i] == "--auth-status":
out[i + 1] = "ALL"
return out
if service == "doc" and command == "+search":
out = drop_flag(args, "--extensions")
out = drop_flag(out, "--created-from")
out = drop_flag(out, "--created-to")
out = drop_flag(out, "--visited-from")
out = drop_flag(out, "--visited-to")
out = drop_flag(out, "--creator-uids")
out = drop_flag(out, "--editor-uids")
out = drop_flag(out, "--mentioned-uids")
out = drop_flag(out, "--workspace-ids")
return out
if service == "doc" and command == "+comment-list":
out = drop_flag(args, "--cursor")
out = drop_flag(out, "--resolve-status")
return out
if service == "doc" and command == "+list":
out = drop_flag(args, "--workspace")
out = drop_flag(out, "--cursor")
return out
if service == "report" and command == "+outbox-list":
return drop_flag(args, "--template-name")
return args
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--service", action="append", help="Only run shortcuts from this service; may repeat")
parser.add_argument("--command", action="append", help="Only run this shortcut command; may repeat")
parser.add_argument("--failed-only", action="store_true", help="Only rerun shortcuts currently marked non-success in the output report")
ns = parser.parse_args()
services = set(ns.service or [])
commands = set(ns.command or [])
matrix = ensure_matrix()
rows = [r for r in matrix["results"] if r.get("risk") == "read"]
if services:
rows = [r for r in rows if r["service"] in services]
if commands:
rows = [r for r in rows if r["command"] in commands]
if ns.failed_only and OUT_PATH.exists():
existing = json.loads(OUT_PATH.read_text(encoding="utf-8"))
failed = {
(r.get("service"), r.get("command"))
for r in existing.get("results", [])
if r.get("status") != "real-ok"
}
rows = [r for r in rows if (r["service"], r["command"]) in failed]
results = []
summary = {"total": len(rows), "ok": 0, "error": 0, "timeout": 0, "held": 0}
for idx, r in enumerate(rows, 1):
key = (r["service"], r["command"])
if key in HELD_CASES:
summary["held"] += 1
item = {
"service": r["service"],
"command": r["command"],
"risk": r["risk"],
"method": "held; sensitive credential read",
"status": "held",
"input": "",
"args": [],
"stdout": "",
"stderr": HELD_CASES[key],
"exit_code": None,
"duration_ms": 0,
}
item = sanitize_result(item)
category, fixability, note = classify_failure(item)
item["failure_category"] = category
item["fixability"] = fixability
item["diagnosis"] = note
results.append(item)
continue
args = adjust_command_args(replace_flag_values(r["args"], r["service"], r["command"]), r["service"], r["command"])
cmd = [BIN] + args
started = time.time()
status = "real-error"
stdout = ""
stderr = ""
exit_code = None
try:
p = subprocess.run(cmd, text=True, capture_output=True, timeout=30)
stdout = p.stdout.strip()
stderr = p.stderr.strip()
exit_code = p.returncode
status = classify_real_status(exit_code, stdout)
if status == "real-ok":
status = "real-ok"
summary["ok"] += 1
else:
summary["error"] += 1
except subprocess.TimeoutExpired as e:
status = "timeout"
summary["timeout"] += 1
if isinstance(e.stdout, str):
stdout = e.stdout.strip()
if isinstance(e.stderr, str):
stderr = e.stderr.strip()
duration_ms = int((time.time() - started) * 1000)
item = {
"service": r["service"],
"command": r["command"],
"risk": r["risk"],
"method": "real-backend-read; no --mock; no --dry-run",
"status": status,
"input": shell_join(cmd),
"args": args,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"duration_ms": duration_ms,
}
item = sanitize_result(item)
category, fixability, note = classify_failure(item)
if category != "passed":
item["failure_category"] = category
item["fixability"] = fixability
item["diagnosis"] = note
results.append(item)
if idx % 25 == 0 or idx == len(rows):
print(
f"progress {idx}/{len(rows)} ok={summary['ok']} "
f"error={summary['error']} timeout={summary['timeout']} held={summary['held']}",
flush=True,
)
if not services and not commands and not ns.failed_only:
OUT_PATH.write_text(json.dumps({
"generated_at": dt.datetime.now().isoformat(),
"summary": summary,
"failure_categories": summarize_failure_categories(results),
"results": results,
}, ensure_ascii=False, indent=2), encoding="utf-8")
if OUT_PATH.exists() and (services or commands or ns.failed_only):
existing = json.loads(OUT_PATH.read_text(encoding="utf-8"))
replace_keys = {(r["service"], r["command"]) for r in results}
merged = [
r for r in existing.get("results", [])
if (r.get("service"), r.get("command")) not in replace_keys
]
merged.extend(results)
else:
merged = results
summary = summarize_results(merged, include_held=True)
OUT_PATH.write_text(json.dumps({
"generated_at": dt.datetime.now().isoformat(),
"summary": summary,
"failure_categories": summarize_failure_categories(merged),
"results": merged,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"saved {OUT_PATH} batch={summarize_results(results, include_held=True)} merged={summary}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -28,7 +28,7 @@ func TestDWSDocsCommandTreeCoverage(t *testing.T) {
t.Skip("no command paths parsed from docs/dws (directory may not exist)")
}
index := buildCommandIndex(app.NewRootCommand())
index := buildCommandIndex(app.NewSchemaSourceRootCommand())
missing := make([]string, 0)
for _, path := range docPaths {
if _, ok := index[path]; ok {
@@ -52,7 +52,7 @@ func TestDWSDocsLocalFlagsCoverage(t *testing.T) {
}
docLeafSet := leafCommandSet(docPaths)
index := buildCommandIndex(app.NewRootCommand())
index := buildCommandIndex(app.NewSchemaSourceRootCommand())
missing := make([]string, 0)
fallbackMatched := 0
explicitMatched := 0
+7
View File
@@ -9,6 +9,12 @@ import (
)
func TestMain(m *testing.M) {
configDir, err := os.MkdirTemp("", "dws-cli-test-config-")
if err != nil {
panic(err)
}
os.Setenv("DWS_CONFIG_DIR", configDir)
// Set an empty catalog fixture so that EnvironmentLoader does not
// attempt live discovery (which would hang on unreachable MCP endpoints).
// Tests that construct app root commands must remain serial because root
@@ -17,5 +23,6 @@ func TestMain(m *testing.M) {
os.Setenv(cli.CatalogFixtureEnv, absFixture)
code := m.Run()
_ = os.RemoveAll(configDir)
os.Exit(code)
}
+177
View File
@@ -46,6 +46,29 @@ var expectedReleaseAdmissionContexts = []string{
"Mock MCP",
}
func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
if err != nil {
t.Fatalf("Abs(verify-package-managers.sh) error = %v", err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
script := string(data)
for _, binary := range []string{`"$vendor_bin"`, `"$prefix/bin/dws"`} {
want := `LC_ALL=C grep -aFq "v$EXPECTED_VERSION" ` + binary
if !strings.Contains(script, want) {
t.Errorf("package-manager verifier is missing raw binary marker check %q", want)
}
}
if strings.Contains(script, `strings "$vendor_bin"`) || strings.Contains(script, `strings "$prefix/bin/dws"`) {
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
}
}
func seedDistArchive(t *testing.T, path string) {
t.Helper()
file, err := os.Create(path)
@@ -637,6 +660,33 @@ func releaseWorkflowSection(t *testing.T, workflow, startMarker, endMarker strin
return workflow[start : start+len(startMarker)+end]
}
func releaseWorkflowRunScript(t *testing.T, workflow, stepName, nextStepName string) string {
t.Helper()
section := releaseWorkflowSection(
t,
workflow,
" - name: "+stepName+"\n",
"\n - name: "+nextStepName+"\n",
)
const runMarker = " run: |\n"
start := strings.Index(section, runMarker)
if start == -1 {
t.Fatalf("release workflow step %q is missing a run block", stepName)
}
lines := strings.Split(section[start+len(runMarker):], "\n")
for i, line := range lines {
if line == "" {
continue
}
if !strings.HasPrefix(line, " ") {
t.Fatalf("release workflow step %q has an unexpected run indentation: %q", stepName, line)
}
lines[i] = strings.TrimPrefix(line, " ")
}
return strings.Join(lines, "\n")
}
func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
@@ -1794,6 +1844,133 @@ func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) {
}
}
func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) {
workflow := readReleaseWorkflow(t)
script := releaseWorkflowRunScript(
t,
workflow,
"Verify npm channel delivery",
"Sync release artifacts to China OSS mirror",
)
tests := []struct {
name string
sequence string
wantSuccess bool
wantCalls int
wantSleeps int
wantOutput string
}{
{
name: "stale beta converges to target",
sequence: "1.0.53-beta.6\n1.0.53-beta.6\n1.0.53-beta.7\n",
wantSuccess: true,
wantCalls: 3,
wantSleeps: 2,
},
{
name: "stale beta never converges",
sequence: "1.0.53-beta.6\n",
wantCalls: 12,
wantSleeps: 11,
wantOutput: "still reports older v1.0.53-beta.6 after 12 attempts",
},
{
name: "permanent registry error fails immediately",
sequence: "__NPM_ERROR__\n",
wantCalls: 1,
wantSleeps: 0,
wantOutput: "permanent npm registry error",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
fakeBin := filepath.Join(root, "bin")
if err := os.MkdirAll(fakeBin, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", fakeBin, err)
}
sequencePath := filepath.Join(root, "sequence")
statePath := filepath.Join(root, "state")
npmLogPath := filepath.Join(root, "npm.log")
sleepLogPath := filepath.Join(root, "sleep.log")
mustWriteFile(t, sequencePath, []byte(test.sequence), 0o644)
mustWriteFile(t, filepath.Join(fakeBin, "npm"), []byte(`#!/bin/sh
set -eu
printf '%s\n' "$*" >> "$NPM_CALL_LOG"
test "$*" = "view dingtalk-workspace-cli dist-tags.beta --registry=https://registry.npmjs.org --prefer-online" || {
echo "unexpected npm mutation: $*" >&2
exit 97
}
call=0
if test -f "$NPM_STATE"; then call="$(cat "$NPM_STATE")"; fi
call=$((call + 1))
printf '%s\n' "$call" > "$NPM_STATE"
value="$(sed -n "${call}p" "$NPM_SEQUENCE")"
if test -z "$value"; then value="$(tail -n 1 "$NPM_SEQUENCE")"; fi
if test "$value" = "__NPM_ERROR__"; then
echo "permanent npm registry error" >&2
exit 42
fi
printf '%s\n' "$value"
`), 0o755)
mustWriteFile(t, filepath.Join(fakeBin, "sleep"), []byte(`#!/bin/sh
set -eu
printf '%s\n' "$*" >> "$SLEEP_CALL_LOG"
`), 0o755)
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repository root) error = %v", err)
}
cmd := exec.Command("sh", "-c", script)
cmd.Dir = repoRoot
cmd.Env = append(os.Environ(),
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"NPM_TAG=beta",
"SEMVER=1.0.53-beta.7",
"NPM_SEQUENCE="+sequencePath,
"NPM_STATE="+statePath,
"NPM_CALL_LOG="+npmLogPath,
"SLEEP_CALL_LOG="+sleepLogPath,
)
output, runErr := cmd.CombinedOutput()
if test.wantSuccess && runErr != nil {
t.Fatalf("npm delivery verification error = %v\noutput:\n%s", runErr, output)
}
if !test.wantSuccess && runErr == nil {
t.Fatalf("npm delivery verification unexpectedly succeeded\noutput:\n%s", output)
}
if test.wantOutput != "" && !strings.Contains(string(output), test.wantOutput) {
t.Errorf("npm delivery verification output is missing %q:\n%s", test.wantOutput, output)
}
npmLog, err := os.ReadFile(npmLogPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", npmLogPath, err)
}
npmCalls := strings.Split(strings.TrimSpace(string(npmLog)), "\n")
if got := len(npmCalls); got != test.wantCalls {
t.Errorf("npm view call count = %d, want %d; log:\n%s", got, test.wantCalls, npmLog)
}
if strings.Contains(string(npmLog), "dist-tag add") || strings.Contains(string(npmLog), "publish") {
t.Errorf("delivery verification must remain read-only; log:\n%s", npmLog)
}
sleepCalls := 0
if sleepLog, err := os.ReadFile(sleepLogPath); err == nil {
sleepCalls = len(strings.Fields(string(sleepLog)))
} else if !os.IsNotExist(err) {
t.Fatalf("ReadFile(%s) error = %v", sleepLogPath, err)
}
if sleepCalls != test.wantSleeps {
t.Errorf("sleep call count = %d, want %d", sleepCalls, test.wantSleeps)
}
})
}
}
func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) {
t.Parallel()
+51 -9
View File
@@ -1494,12 +1494,12 @@ func TestReleaseContractRejectsDirtyOrUnsyncedMain(t *testing.T) {
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "must exactly match origin/main") {
if err == nil || !strings.Contains(output, "must be contained in origin/main history") {
t.Fatalf("unsynced main was not blocked: err=%v\noutput:\n%s", err, output)
}
}
func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
func TestReleaseContractStablePromotionRequiresBetaAncestry(t *testing.T) {
t.Run("sealed", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
@@ -1518,12 +1518,12 @@ func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
}
})
t.Run("source drift", func(t *testing.T) {
t.Run("commits after beta", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
mustWriteFile(t, filepath.Join(r.root, "drift.txt"), []byte("untested change\n"), 0o644)
r.commitAndPush(t, "drift after beta")
mustWriteFile(t, filepath.Join(r.root, "followup.txt"), []byte("merged after beta\n"), 0o644)
r.commitAndPush(t, "merge follow-up after beta")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
@@ -1532,11 +1532,53 @@ func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil {
t.Fatalf("drifted stable promotion unexpectedly passed:\n%s", output)
if err != nil {
t.Fatalf("stable promotion with commits after beta error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "only CHANGELOG.md may differ") || !strings.Contains(output, "drift.txt") {
t.Fatalf("drift output is not actionable:\n%s", output)
})
t.Run("beta outside HEAD history", func(t *testing.T) {
r := newReleaseTestRepo(t)
mustRun(t, r.root, "git", "checkout", "-b", "sidecar")
mustWriteFile(t, filepath.Join(r.root, "sidecar.txt"), []byte("never merged\n"), 0o644)
mustRun(t, r.root, "git", "add", ".")
mustRun(t, r.root, "git", "commit", "-m", "sidecar beta candidate")
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1-beta.1", "-m", "Release v1.0.1-beta.1", "-m", "Channel: prerelease")
mustRun(t, r.root, "git", "checkout", "main")
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
r.commitAndPush(t, "prepare stable changelog")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "not an ancestor of HEAD") {
t.Fatalf("beta outside HEAD history was promoted: err=%v\noutput:\n%s", err, output)
}
})
t.Run("older sealed commit after main advanced", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
r.commitAndPush(t, "prepare stable changelog")
sealed := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD"))
mustWriteFile(t, filepath.Join(r.root, "after.txt"), []byte("main advanced\n"), 0o644)
r.commitAndPush(t, "advance main after stable candidate")
mustRun(t, r.root, "git", "checkout", "--detach", sealed)
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err != nil {
t.Fatalf("older sealed commit in main history was rejected: %v\noutput:\n%s", err, output)
}
})
}