Files
Cyrene_v2/internal/services/gateway/plugin_origin_test.go
T
AskaEth c608dd4afb fix(gateway): 卸载拒绝话术按接入路径分流(.env 接入的 server 不再指向 MCP 页)
复审 M30-p2-1(r1):`plugin_uninstall_scope` 的 400 一律让主人「去『MCP 服务器』页
卸载」,但 `.env` 的 MCP_SERVERS_JSON 启动期接入的 server **不经**插件管理器、
不在 plugins.json 里,MCP 页(= Manager.Statuses()/List())根本看不到它 ⇒ 那条指引
对该路径是假指引。

- 新增 `plugin.(Manager) MCPServerName`:`mcp:<server>` 注册键 → server 名的**唯一**
  解析口(前缀只在 manager.go 出现一次),网关不再自己拼前缀。
- `serverScopedUninstall` 改为 `*Server` 方法,文案经 `uninstallPointer` 分流:
  · 管理器托管(落在 plugins.json,判据 `Manager.List()`)⇒ 保留「请在『MCP 服务器』
    页启停或卸载该 server」;
  · 否则 ⇒ 明说该 server 由 .env 的 MCP_SERVERS_JSON 在启动时接入、面板不管理它,
    并给出可操作步骤(改 .env 里那一项:删掉或设 enabled=false,改完需重启生效)。
  接入路径判据来自**管理器持久清单**,不是名字前缀/transport 推断。
- `mcp:<server>` 登记条目同样分流(此前也一律指向 MCP 页)。

测试(gateway):plugins.json 路径断言含「MCP 服务器」页字样且不提 MCP_SERVERS_JSON;
.env 路径(工具与登记条目各一条)断言含 MCP_SERVERS_JSON 且**不含**「MCP 服务器」
字样;三条均断言 400 + `plugin_uninstall_scope` 不变、被拒后条目仍在。
修前红:两条 .env 用例(见回报);修后绿。回归 go build/vet/test 全绿。
2026-10-24 20:32:36 +08:00

332 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住「插件与能力」读面的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
// §2 方案① / §6 契约增量),以及与之配套的服务端拒绝:
//
// - 来自 MCP server 的工具带 `origin:{kind,server,transport}`;
// - 宿主内置工具的该键**缺席**(不是 `{}`、不是 `null`)—— 缺席才表示「没有上游」;
// - 只改呈现:`allowInGroup`/`allowInStranger` 逐字不变,`kind` 仍是 `tool`;
// - 对一把 MCP 工具调 `POST /admin/plugins/uninstall` 必须 400(绕开管理器裸删会让
// server 的 names 账本不同步),而普通能力的卸载行为不变。
// originStubTool 是自报来源的最小工具。
type originStubTool struct {
name string
origin sdk.Origin
}
func (t originStubTool) Name() string { return t.name }
func (t originStubTool) Description() string { return "MCP 工具" }
func (t originStubTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (t originStubTool) Execute(_ context.Context, _ any) (any, error) {
return nil, nil
}
func (t originStubTool) Origin() sdk.Origin { return t.origin }
// builtinStubTool 是宿主内置工具(不实现 sdk.OriginAware)。
type builtinStubTool struct{ name string }
func (t builtinStubTool) Name() string { return t.name }
func (t builtinStubTool) Description() string { return "内置工具" }
func (t builtinStubTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (t builtinStubTool) Execute(_ context.Context, _ any) (any, error) {
return nil, nil
}
// listPlugins 取管理读面的能力清单(保留原始 JSON 以便断言键的**缺席**)。
func listPlugins(t *testing.T, url, token string) []byte {
t.Helper()
resp, raw := doReq(t, http.MethodGet, url+"/api/v1/admin/plugins", nil, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("plugins list status = %d, body = %s", resp.StatusCode, raw)
}
return raw
}
// TestAdminListPluginsExposesOrigin 是读面的主判据:MCP 工具带章、内置工具缺席。
func TestAdminListPluginsExposesOrigin(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
mcpOrigin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: mcpOrigin}); err != nil {
t.Fatalf("register mcp tool: %v", err)
}
if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil {
t.Fatalf("register builtin tool: %v", err)
}
raw := listPlugins(t, env.ts.URL, env.token)
var list struct {
Data []PluginSummary `json:"data"`
}
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatalf("decode list: %v (%s)", err, raw)
}
byName := make(map[string]PluginSummary, len(list.Data))
for _, it := range list.Data {
byName[it.Name] = it
}
mcpCap, ok := byName["read_file_mcp"]
if !ok {
t.Fatalf("MCP 工具不在清单里:%s", raw)
}
if mcpCap.Origin == nil {
t.Fatalf("MCP 工具没有 origin:%+v", mcpCap)
}
if *mcpCap.Origin != (PluginOrigin{Kind: "mcp", Server: "fs", Transport: "stdio"}) {
t.Fatalf("origin = %+v, want {mcp fs stdio}", *mcpCap.Origin)
}
// 呈现层新增不改既有语义:kind 与两个策略布尔逐字不变。
if mcpCap.Kind != "tool" || mcpCap.AllowInGroup || mcpCap.AllowInStranger {
t.Fatalf("既有字段被改了:%+v", mcpCap)
}
builtin, ok := byName["web_search"]
if !ok {
t.Fatalf("内置工具不在清单里:%s", raw)
}
if builtin.Origin != nil {
t.Fatalf("内置工具带上了 origin:%+v", builtin.Origin)
}
// 🔴 线上必须是「键缺席」而不是空对象:`"origin":{}` / `"origin":null` 都会被
// 前端/严格解析器读成「有来源但内容为空」。
entry := rawEntryOf(t, raw, "web_search")
if strings.Contains(entry, "origin") {
t.Fatalf("内置工具的条目里出现了 origin 键:%s", entry)
}
}
// rawEntryOf 从原始响应里截出某个名为 name 的条目的 JSON 片段,用于断言键的缺席
// (反序列化后的结构体无法区分「缺键」与「零值」)。
func rawEntryOf(t *testing.T, raw []byte, name string) string {
t.Helper()
var items []json.RawMessage
var envelope struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err == nil {
items = envelope.Data
} else if err := json.Unmarshal(raw, &items); err != nil {
t.Fatalf("decode raw list: %v (%s)", err, raw)
}
for _, item := range items {
var probe struct {
Name string `json:"name"`
}
if err := json.Unmarshal(item, &probe); err != nil {
t.Fatalf("decode item: %v (%s)", err, item)
}
if probe.Name == name {
return string(item)
}
}
t.Fatalf("条目 %q 不在清单里:%s", name, raw)
return ""
}
// TestAdminPluginUninstallRejectsMCPTool 是那条 high finding 的服务端一半:
// 单把 MCP 工具不能绕过插件管理器裸删(消息该指向哪,按接入路径另有专门用例)。
func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: origin}); err != nil {
t.Fatalf("register mcp tool: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "read_file_mcp"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400(单把 MCP 工具不能在这里卸载),body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if _, err := env.caps.Get("read_file_mcp"); err != nil {
t.Fatalf("被拒的卸载却把工具删了:%v", err)
}
}
// installMCPPlugin 通过 install 端点装一台 server(写进 plugins.json)。
// 用于「这台 server 由插件管理器托管」的路径判定。
func installMCPPlugin(t *testing.T, env *pluginTestEnv, name string) {
t.Helper()
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
"kind": "mcp",
"name": name,
"config": map[string]any{"transport": "http", "endpoint": env.mcpURL},
}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("install %q status = %d, body = %s", name, resp.StatusCode, raw)
}
}
// TestAdminPluginUninstallMCPToolPointsToPluginPage:server 在 plugins.json 里
// ⇒ 文案指向「MCP 服务器」页(那页确实能卸载它),**不**提 .env。
func TestAdminPluginUninstallMCPToolPointsToPluginPage(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, newFakeMCPHandler("", "add"))
installMCPPlugin(t, env, "calc")
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "add"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if !strings.Contains(string(raw), "MCP 服务器") {
t.Fatalf("plugins.json 装出来的 server 应指向「MCP 服务器」页:%s", raw)
}
if strings.Contains(string(raw), "MCP_SERVERS_JSON") {
t.Fatalf("plugins.json 路径不该提 .env 的 MCP_SERVERS_JSON:%s", raw)
}
if _, err := env.caps.Get("add"); err != nil {
t.Fatalf("被拒的卸载却把工具删了:%v", err)
}
}
// TestAdminPluginUninstallMCPToolPointsToEnvPath:server **不在** plugins.json
// ⇒ 它是 `.env` 的 MCP_SERVERS_JSON 启动期接入的,而「MCP 服务器」页只列 manager
// 托管的那批(看不见它)⇒ 文案必须让主人去改 .env,且**不得**指向那页。
func TestAdminPluginUninstallMCPToolPointsToEnvPath(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "envcalc", Transport: "stdio"}
if err := env.caps.RegisterTool(originStubTool{name: "env_add", origin: origin}); err != nil {
t.Fatalf("register env-path tool: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "env_add"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if !strings.Contains(string(raw), "MCP_SERVERS_JSON") {
t.Fatalf(".env 接入的 server 应指向 MCP_SERVERS_JSON:%s", raw)
}
if strings.Contains(string(raw), "MCP 服务器") {
t.Fatalf(".env 接入的 server 不得指向「MCP 服务器」页(那页看不到它):%s", raw)
}
if _, err := env.caps.Get("env_add"); err != nil {
t.Fatalf("被拒的卸载却把工具删了:%v", err)
}
}
// TestAdminPluginUninstallMCPEntryPointsToEnvPath:`mcp:<server>` 登记条目同样要按
// 接入路径给指引 —— env 路径下也**不得**指向「MCP 服务器」页。
func TestAdminPluginUninstallMCPEntryPointsToEnvPath(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
if err := env.caps.Register(capreg.Capability{
Name: "mcp:envcalc", Kind: capreg.KindMCP, Description: "MCP server envcalc", Value: struct{}{},
}); err != nil {
t.Fatalf("register mcp entry: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "mcp:envcalc"}, env.token)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
}
if !strings.Contains(string(raw), "MCP_SERVERS_JSON") {
t.Fatalf(".env 接入的登记条目应指向 MCP_SERVERS_JSON:%s", raw)
}
if strings.Contains(string(raw), "MCP 服务器") {
t.Fatalf(".env 接入的登记条目不得指向「MCP 服务器」页:%s", raw)
}
if _, err := env.caps.Get("mcp:envcalc"); err != nil {
t.Fatalf("被拒的卸载却把登记条目删了:%v", err)
}
}
// TestAdminPluginUninstallStillRemovesPlainCapability:普通能力(技能/内置工具)
// 的卸载路径逐字不变 —— 拒绝只针对「有 MCP 来源」的条目。
func TestAdminPluginUninstallStillRemovesPlainCapability(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil {
t.Fatalf("register builtin tool: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "web_search"}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200, body = %s", resp.StatusCode, raw)
}
if _, err := env.caps.Get("web_search"); err == nil {
t.Fatal("普通能力没有被卸载")
}
}
// TestInstalledMCPServerToolsCarryOrigin 走**热装整链**(install 端点 → 插件管理器 →
// mcp.Registry → RegisterTool):装完那台 server 的工具在管理读面上带 origin,
// 而按 server 名卸载后条目**连 origin 一起消失**,不留孤儿。
//
// 它与 `internal/capability/mcp` 的盖章用例互补:那条钉的是两条接入路径的合流处
// (RegisterTool),这条钉的是运行时这条路径真的走到了那里。
func TestInstalledMCPServerToolsCarryOrigin(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, newFakeMCPHandler("", "add", "mul"))
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
"kind": "mcp",
"name": "calc",
"config": map[string]any{"transport": "http", "endpoint": env.mcpURL},
}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw)
}
var list struct {
Data []PluginSummary `json:"data"`
}
if err := json.Unmarshal(listPlugins(t, env.ts.URL, env.token), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
want := PluginOrigin{Kind: "mcp", Server: "calc", Transport: "http"}
tools := 0
for _, it := range list.Data {
if it.Kind != "tool" {
continue
}
tools++
if it.Origin == nil || *it.Origin != want {
t.Fatalf("工具 %q 的 origin = %+v, want %+v", it.Name, it.Origin, want)
}
}
if tools != 2 {
t.Fatalf("工具数 = %d, want 2(add/mul)", tools)
}
// 按 server 名卸载(这是**合法**路径,管理器会连工具带账本一起收),
// 卸载后工具条目连 origin 一起消失 —— 「停用 server ⇒ 工具消失」语义不变。
resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
map[string]any{"name": "calc"}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw)
}
if left := env.caps.List(capreg.KindTool); len(left) != 0 {
t.Fatalf("卸载 server 后还剩 %d 把工具(不应留下带 origin 的孤儿)", len(left))
}
}