design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。
- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
· Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
· SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
· 新增 Reconnect(对已停用者拒绝,不替用户启用);
· 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
lastError 在记录那一刻即脱敏截断);
· 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
· 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
186 lines
7.0 KiB
Go
186 lines
7.0 KiB
Go
package plugin
|
||
|
||
// 本文件锁定 MCP 服务器状态快照的三条硬要求(切片 M20):
|
||
// ① 三态:缺席 = 未知,不用零值冒充「未连接」;
|
||
// ② 凭据绝不出现(apiKey 及其任意片段),带变异自证;
|
||
// ③ 连接失败如实给 lastError + connected=false,且失败的那台也出现在快照里
|
||
// (否则「为什么挂了」在面板上根本无处可看)。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// statusOfOne 返回快照里某台 server 的那一条(没有就 fail)。
|
||
func statusOfOne(t *testing.T, mgr *Manager, name string) Status {
|
||
t.Helper()
|
||
for _, st := range mgr.Statuses() {
|
||
if st.Name == name {
|
||
return st
|
||
}
|
||
}
|
||
t.Fatalf("快照里没有 %q:%+v", name, mgr.Statuses())
|
||
return Status{}
|
||
}
|
||
|
||
// TestStatusesTriState 锁定 ①:三种「知道」程度各自长什么样。
|
||
func TestStatusesTriState(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||
srv := newFakeMCP(t, "", "add")
|
||
mgr, _ := newEnabledManager(t, path, srv.URL)
|
||
|
||
// 连着:connected=true、工具数/明细/最近连接都在。
|
||
live := statusOfOne(t, mgr, "calc")
|
||
if live.State != ServerStateConnected {
|
||
t.Fatalf("state = %q,期望 connected", live.State)
|
||
}
|
||
if live.Connected == nil || !*live.Connected || live.ToolCount == nil || *live.ToolCount != 1 {
|
||
t.Fatalf("连接态快照不对 = %+v", live)
|
||
}
|
||
if live.LastSeenAt == "" || live.LastError != "" {
|
||
t.Fatalf("活着时应当有 lastSeenAt 且没有 lastError:%+v", live)
|
||
}
|
||
if live.Endpoint != srv.URL {
|
||
t.Fatalf("endpoint = %q,期望 %q", live.Endpoint, srv.URL)
|
||
}
|
||
|
||
// 停用:connected 是**确认的 false**(有意没连),工具则是未知。
|
||
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
|
||
t.Fatalf("SetEnabled(false): %v", err)
|
||
}
|
||
off := statusOfOne(t, mgr, "calc")
|
||
if off.State != ServerStateDisabled || off.Connected == nil || *off.Connected {
|
||
t.Fatalf("停用快照 = %+v", off)
|
||
}
|
||
if off.ToolCount != nil || off.Tools != nil {
|
||
t.Fatalf("停用时工具应当是**未知**(缺席),得到 %+v / %+v", off.ToolCount, off.Tools)
|
||
}
|
||
|
||
// 未知:文件里有这一条,但本次进程从没连过也没失败过(手工改文件就是这样)。
|
||
handEdited := runtimeconfig.PluginConfig{Name: "manual", Kind: runtimeconfig.PluginKindMCP,
|
||
Transport: runtimeconfig.TransportHTTP, Endpoint: "https://example.invalid/mcp"}
|
||
if err := runtimeconfig.SavePlugins(path, []runtimeconfig.PluginConfig{handEdited}); err != nil {
|
||
t.Fatalf("SavePlugins: %v", err)
|
||
}
|
||
unknown := statusOfOne(t, mgr, "manual")
|
||
if unknown.State != ServerStateUnknown {
|
||
t.Fatalf("state = %q,期望 unknown", unknown.State)
|
||
}
|
||
if unknown.Connected != nil {
|
||
t.Fatalf("未知的 connected 必须**缺席**(不得用零值冒充未连接),得到 %v", *unknown.Connected)
|
||
}
|
||
if unknown.ToolCount != nil || unknown.LastSeenAt != "" || unknown.LastError != "" {
|
||
t.Fatalf("未知时不该有工具/时间/错误:%+v", unknown)
|
||
}
|
||
}
|
||
|
||
// TestStatusesFailureIsVisible 锁定 ③:连不上的**新装**attempt 也要出现在快照里。
|
||
func TestStatusesFailureIsVisible(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||
dead := httptest.NewServer(http.NotFoundHandler())
|
||
deadURL := dead.URL
|
||
dead.Close()
|
||
|
||
mgr := NewManager(registry.New(), path, nil)
|
||
defer mgr.Close(context.Background())
|
||
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
||
Name: "down", Transport: runtimeconfig.TransportHTTP, Endpoint: deadURL,
|
||
}); err == nil {
|
||
t.Fatal("对关掉的 server 安装应当失败")
|
||
}
|
||
st := statusOfOne(t, mgr, "down")
|
||
if st.State != ServerStateError || st.Connected == nil || *st.Connected {
|
||
t.Fatalf("失败快照 = %+v,期望 error 且 connected=false", st)
|
||
}
|
||
if st.LastError == "" {
|
||
t.Fatal("失败快照没有 lastError:面板说不出病因")
|
||
}
|
||
if st.Enabled != true {
|
||
t.Fatalf("失败的那台是「想启用但没连上」,enabled 应为 true:%+v", st)
|
||
}
|
||
}
|
||
|
||
// TestStatusesNeverLeakAPIKey 锁定 ②:快照 JSON 里不得出现 apiKey 或它的任意片段。
|
||
//
|
||
// 覆盖两条最容易漏的路:① 正常快照里的配置字段;② 失败文本(MCP 客户端/上游可能把
|
||
// Bearer 令牌写进错误里)。片段检查是「变异自证」:把密钥切成若干 8 字符片段再各查一次。
|
||
func TestStatusesNeverLeakAPIKey(t *testing.T) {
|
||
t.Parallel()
|
||
const key = "sk-live-SECRET-9f3c2b1d"
|
||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||
srv := newFakeMCP(t, key, "add")
|
||
mgr := NewManager(registry.New(), path, nil)
|
||
defer mgr.Close(context.Background())
|
||
if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
|
||
Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: srv.URL, APIKey: key,
|
||
}); err != nil {
|
||
t.Fatalf("Install: %v", err)
|
||
}
|
||
cfgs, err := runtimeconfig.LoadPlugins(path)
|
||
if err != nil || len(cfgs) != 1 {
|
||
t.Fatalf("LoadPlugins = %+v, %v", cfgs, err)
|
||
}
|
||
cfgWithKey := cfgs[0]
|
||
// 失败文本里塞进整把钥匙和它的中段(这正是「上游把凭据回显进 401」的形状)。
|
||
mgr.noteFailure(cfgWithKey, errors.New("401 Unauthorized: bearer "+key+" rejected; echo=SECRET-9f3"))
|
||
mgr.noteFailure(cfgWithKey, errors.New("dial "+srv.URL+"?api_key="+key+" failed"))
|
||
|
||
raw, err := json.Marshal(mgr.Statuses())
|
||
if err != nil {
|
||
t.Fatalf("marshal statuses: %v", err)
|
||
}
|
||
body := string(raw)
|
||
assertNoSecret(t, body, key)
|
||
if !strings.Contains(body, "failed") {
|
||
t.Fatalf("抹凭据不该把病因整条抹掉:%s", body)
|
||
}
|
||
}
|
||
|
||
// TestRedactPluginSecrets 直接钉住抹除函数:整把、前缀、中段、后缀都不许留下。
|
||
func TestRedactPluginSecrets(t *testing.T) {
|
||
t.Parallel()
|
||
const key = "tok_ABCDEF123456"
|
||
cfg := runtimeconfig.PluginConfig{APIKey: key,
|
||
Env: map[string]string{"GITHUB_TOKEN": "ghp_ZZZZZZ9999", "PLAIN": "keep-me"}}
|
||
cases := []string{
|
||
"authorization: Bearer " + key,
|
||
"prefix " + key[:8] + " suffix",
|
||
"middle ABCDEF123456 and more",
|
||
"env dump GITHUB_TOKEN=ghp_ZZZZZZ9999",
|
||
}
|
||
for _, in := range cases {
|
||
out := runtimeconfig.RedactPluginSecrets(cfg, in)
|
||
assertNoSecret(t, out, key)
|
||
assertNoSecret(t, out, "ghp_ZZZZZZ9999")
|
||
}
|
||
// 非敏感值不该被连带抹掉(否则排障信息会被无谓地毁掉)。
|
||
if out := runtimeconfig.RedactPluginSecrets(cfg, "plain keep-me"); !strings.Contains(out, "keep-me") {
|
||
t.Fatalf("非敏感值被误抹:%q", out)
|
||
}
|
||
}
|
||
|
||
// assertNoSecret 断言 text 不含 secret 本身,也不含它的任意 8 字符片段。
|
||
func assertNoSecret(t *testing.T, text, secret string) {
|
||
t.Helper()
|
||
if strings.Contains(text, secret) {
|
||
t.Fatalf("文本里出现了完整凭据 %q:%s", secret, text)
|
||
}
|
||
const frag = 8
|
||
for i := 0; i+frag <= len(secret); i++ {
|
||
if part := secret[i : i+frag]; strings.Contains(text, part) {
|
||
t.Fatalf("文本里出现了凭据片段 %q:%s", part, text)
|
||
}
|
||
}
|
||
}
|