* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions The canonical 'dws mcp' tree is built from cached catalog data before the legacy command build and before Cobra dispatches anything, so a pflag panic there (a tool schema property named after the reserved --params flag, as cached during the 1.0.32 incident) aborted every invocation -- including 'dws cache refresh' and 'dws upgrade' -- and sat outside all three poisoned-cache guards (#447/#449/#452). Two layers, mirroring the existing guards: - applyFlagSpecs now skips reserved (--json/--params), duplicate and alias-colliding flag names and sanitizes shorthands instead of letting pflag panic; the skipped property stays reachable through the reserved JSON payload flags (same degradation semantics as #449). - newMCPCommand wraps the build in the #452 recover -> quarantine -> retry-once -> degrade-to-stub sequence, so even an unforeseen panic class no longer locks the CLI out. * docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
158 lines
5.8 KiB
Go
158 lines
5.8 KiB
Go
// 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"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
|
|
// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
|
|
// every poisoned-cache guard, so a panic there (e.g. a tool schema property
|
|
// named after the reserved --params flag) aborted every invocation. With no
|
|
// on-disk cache to quarantine it must degrade to an inert stub instead.
|
|
func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
|
|
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
|
|
|
calls := 0
|
|
orig := buildMCPCommandFn
|
|
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
|
calls++
|
|
panic("chat_permission_grant flag redefined: params")
|
|
}
|
|
t.Cleanup(func() { buildMCPCommandFn = orig })
|
|
|
|
var cmd *cobra.Command
|
|
captured := captureStderr(t, func() {
|
|
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
|
})
|
|
|
|
if cmd == nil || cmd.Name() != "mcp" {
|
|
t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
|
|
}
|
|
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
|
|
t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
|
|
}
|
|
if !strings.Contains(captured, "dws cache refresh") {
|
|
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
|
|
}
|
|
}
|
|
|
|
// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
|
|
// the build panics AND a discovery cache exists on disk, the partition is
|
|
// quarantined and the build retried once, so a fixed binary escapes the
|
|
// lock-out with zero manual cache surgery.
|
|
func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv(cli.CacheDirEnv, tmp)
|
|
|
|
store := cache.NewStore(tmp)
|
|
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
|
t.Fatalf("SaveTools() error = %v", err)
|
|
}
|
|
|
|
calls := 0
|
|
orig := buildMCPCommandFn
|
|
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
|
calls++
|
|
if calls == 1 {
|
|
panic("chat_permission_grant flag redefined: params")
|
|
}
|
|
return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
|
|
}
|
|
t.Cleanup(func() { buildMCPCommandFn = orig })
|
|
|
|
var cmd *cobra.Command
|
|
captured := captureStderr(t, func() {
|
|
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
|
})
|
|
|
|
if calls != 2 {
|
|
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
|
}
|
|
if cmd == nil || cmd.Short != "rebuilt-probe" {
|
|
t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
|
|
}
|
|
quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
|
|
if len(quarantines) != 1 {
|
|
t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
|
|
}
|
|
if !strings.Contains(captured, "rebuilding from a fresh fetch") {
|
|
t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
|
|
}
|
|
}
|
|
|
|
// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
|
|
// if the rebuild after quarantine panics again, the stub is returned and the
|
|
// `dws cache refresh` hint kept.
|
|
func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
t.Setenv(cli.CacheDirEnv, tmp)
|
|
|
|
store := cache.NewStore(tmp)
|
|
if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
|
|
t.Fatalf("SaveTools() error = %v", err)
|
|
}
|
|
|
|
calls := 0
|
|
orig := buildMCPCommandFn
|
|
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
|
calls++
|
|
panic("chat_permission_grant flag redefined: params")
|
|
}
|
|
t.Cleanup(func() { buildMCPCommandFn = orig })
|
|
|
|
var cmd *cobra.Command
|
|
captured := captureStderr(t, func() {
|
|
cmd = newMCPCommand(context.Background(), nil, nil, nil)
|
|
})
|
|
|
|
if calls != 2 {
|
|
t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
|
|
}
|
|
if cmd == nil || cmd.Name() != "mcp" {
|
|
t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
|
|
}
|
|
if !strings.Contains(captured, "dws cache refresh") {
|
|
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
|
|
}
|
|
}
|
|
|
|
// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
|
|
// on the happy path.
|
|
func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
|
|
orig := buildMCPCommandFn
|
|
buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
|
|
return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
|
|
}
|
|
t.Cleanup(func() { buildMCPCommandFn = orig })
|
|
|
|
cmd := newMCPCommand(context.Background(), nil, nil, nil)
|
|
if cmd == nil || cmd.Short != "canonical-probe" {
|
|
t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
|
|
}
|
|
}
|