feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照
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。
This commit is contained in:
1 parent
4ed5925296
commit
9654ce8861
14 files changed
+1969
-115
No files matched your search
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -61,10 +62,30 @@ type PluginConfig struct {
|
||||
// Env is extra environment for a stdio subprocess. Values under a sensitive
|
||||
// key (token/secret/password/key...) are redacted by MaskedPlugin.
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
// Enabled is the per-server on/off switch. It is a pointer so that "the file
|
||||
// has no enabled key" (every plugins.json written before this field existed)
|
||||
// stays distinguishable from an explicit false: absent means enabled
|
||||
// (IsEnabled), and only an explicit disable is persisted.
|
||||
//
|
||||
// Disabled is NOT uninstalled: the entry stays in plugins.json (so it survives
|
||||
// a restart as disabled) and only its live connection + tools go away.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// CreatedAt is the RFC3339 install time.
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// IsEnabled reports whether the server should be connected. A missing enabled key
|
||||
// (older plugins.json / an install that never mentioned it) counts as enabled.
|
||||
func (p PluginConfig) IsEnabled() bool {
|
||||
return p.Enabled == nil || *p.Enabled
|
||||
}
|
||||
|
||||
// WithEnabled returns a copy of p with an explicit enabled flag.
|
||||
func (p PluginConfig) WithEnabled(on bool) PluginConfig {
|
||||
p.Enabled = &on
|
||||
return p
|
||||
}
|
||||
|
||||
// DefaultPluginsPath returns the plugins.json path under dataDir.
|
||||
func DefaultPluginsPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, pluginsFileName)
|
||||
@@ -245,6 +266,64 @@ func maskEnv(env map[string]string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// secretMask is what a redacted secret (or a fragment of one) collapses to inside
|
||||
// free text. It is deliberately not maskToken: free text needs a marker that
|
||||
// cannot be mistaken for a real tail of the credential.
|
||||
const secretMask = "***"
|
||||
|
||||
// minRedactFragment is the shortest run of a secret that is still scrubbed from
|
||||
// free text. Anything shorter is not evidence of that credential (a 2-3 char run
|
||||
// of a hex key shows up in ordinary prose by chance), and keeping the floor low
|
||||
// enough that a leaked *middle* of a key (an error printing a substring) is still
|
||||
// caught is the point of scanning fragments at all.
|
||||
const minRedactFragment = 6
|
||||
|
||||
// RedactPluginSecrets scrubs every configured credential out of text, returning a
|
||||
// string that is safe to show in a status snapshot or a log line.
|
||||
//
|
||||
// Why this exists (and why MaskedPlugin is not enough): a connect failure's error
|
||||
// text is written by the MCP client, the HTTP stack or the server itself, so it
|
||||
// can carry the bearer token (a 401 echoing the Authorization header, a URL with
|
||||
// `?token=...`, a stdio subprocess dumping its env). The snapshot exposes that
|
||||
// text as `lastError`, therefore the text — not just the config — has to be
|
||||
// redacted. Replacement is by whole value first, then by every fragment of at
|
||||
// least minRedactFragment characters, so a partially printed key is caught too.
|
||||
func RedactPluginSecrets(p PluginConfig, text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
out := redactSecret(text, p.APIKey)
|
||||
keys := make([]string, 0, len(p.Env))
|
||||
for k := range p.Env {
|
||||
if isSensitiveKey(k) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
// 排序只为结果稳定:Env 是 map,不排序会让同一份输入在不同运行里产出不同文本。
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
out = redactSecret(out, p.Env[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactSecret replaces secret and every fragment of it (longest first, down to
|
||||
// minRedactFragment runes-worth of bytes) with secretMask.
|
||||
func redactSecret(text, secret string) string {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" || text == "" {
|
||||
return text
|
||||
}
|
||||
out := strings.ReplaceAll(text, secret, secretMask)
|
||||
// 长片段优先:先抹掉最长的,剩下的短片段才不会被抹成一片 *** 后漏掉边界。
|
||||
for n := len(secret) - 1; n >= minRedactFragment; n-- {
|
||||
for i := 0; i+n <= len(secret); i++ {
|
||||
out = strings.ReplaceAll(out, secret[i:i+n], secretMask)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// samePlugin reports whether cur is the entry p replaces: same name when p has
|
||||
// one, otherwise same id.
|
||||
func samePlugin(cur, p PluginConfig) bool {
|
||||
|
||||
@@ -186,3 +186,64 @@ func TestMaskedPlugin_RedactsKeyAndEnv(t *testing.T) {
|
||||
t.Fatalf("masked JSON leaks a secret: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginEnabled_BackwardCompatible 锁定 enabled 的向后兼容:既有 plugins.json
|
||||
// (没有 enabled 键)必须被当成**启用**,而显式 false 必须原样存活(含重启)。
|
||||
func TestPluginEnabled_BackwardCompatible(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
legacy := `[{"name":"calc","kind":"mcp","transport":"http","endpoint":"https://x"}]`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
|
||||
t.Fatalf("write legacy file: %v", err)
|
||||
}
|
||||
got, err := LoadPlugins(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPlugins: %v", err)
|
||||
}
|
||||
if len(got) != 1 || !got[0].IsEnabled() {
|
||||
t.Fatalf("老文件(无 enabled 键)应视为启用:%+v", got)
|
||||
}
|
||||
if got[0].Enabled != nil {
|
||||
t.Fatalf("缺失的 enabled 必须保持 nil(= 缺席),得到 %v", *got[0].Enabled)
|
||||
}
|
||||
|
||||
off := got[0].WithEnabled(false)
|
||||
if err := SavePlugins(path, []PluginConfig{off}); err != nil {
|
||||
t.Fatalf("SavePlugins: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"enabled": false`) {
|
||||
t.Fatalf("显式停用必须落盘:%s", raw)
|
||||
}
|
||||
back, err := LoadPlugins(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPlugins: %v", err)
|
||||
}
|
||||
if back[0].IsEnabled() {
|
||||
t.Fatalf("重启后停用态丢失:%+v", back[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactPluginSecrets_ScrubsFragments 锁定「凭据任意片段也不许泄漏」。
|
||||
func TestRedactPluginSecrets_ScrubsFragments(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "sk-live-0123456789abcdef"
|
||||
p := PluginConfig{APIKey: key, Env: map[string]string{"GH_TOKEN": "ghp_secret_value"}}
|
||||
for _, text := range []string{
|
||||
"bearer " + key,
|
||||
"echo " + key[8:20],
|
||||
"tail " + key[len(key)-6:],
|
||||
"env GH_TOKEN=ghp_secret_value",
|
||||
} {
|
||||
out := RedactPluginSecrets(p, text)
|
||||
if strings.Contains(out, key) || strings.Contains(out, "ghp_secret_value") {
|
||||
t.Fatalf("整把凭据仍在:%q", out)
|
||||
}
|
||||
if strings.Contains(out, key[8:20]) || strings.Contains(out, key[len(key)-6:]) {
|
||||
t.Fatalf("凭据片段仍在:%q", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user