fix(plugin): 拒收 env/args 的非字符串值 + 保留键收成内置 id 集合 + 启用落盘失败文案
r1 复审 P2-1 + 两条顺手件。
P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是
前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。
现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值);
盘上零变更、注册表零工具、快照零行(测试同时断言)。
顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search /
browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被
卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。
顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与
事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开;
`state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。
测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。
This commit is contained in:
1 parent
2421a8dac9
commit
2142de92f2
4 files changed
+297
-46
No files matched your search
@@ -87,16 +87,10 @@ func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := runtimeconfig.PluginConfig{
|
||||
Name: actionText(values, FieldServerName),
|
||||
Kind: runtimeconfig.PluginKindMCP,
|
||||
Transport: actionText(values, FieldTransport),
|
||||
Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")),
|
||||
Command: actionText(values, FieldCommand),
|
||||
Args: actionStringList(values[FieldArgs]),
|
||||
APIKey: actionText(values, FieldAPIKey),
|
||||
Env: actionStringMap(values[FieldEnv]),
|
||||
}.Normalize()
|
||||
cfg, err := installConfigOf(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err)
|
||||
}
|
||||
@@ -114,6 +108,40 @@ func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// installConfigOf 把表单值翻译成插件配置。
|
||||
//
|
||||
// `args` / `env` 的形状错**必须在这里变成 400**:前端的 `kv` 控件是自由 JSON textarea,
|
||||
// 主人写 `{"PORT":8080}` 是最自然的写法,若后端把非字符串值静默丢掉,结果就是「装完提示
|
||||
// 成功、server 也连上了、env 却没生效」—— 正是任务书点名要防的「看着配好了、其实没生效」。
|
||||
// 同理 `args` 里的非字符串元素也不许悄悄消失。
|
||||
func installConfigOf(values map[string]any) (runtimeconfig.PluginConfig, error) {
|
||||
args, err := actionStringList(values[FieldArgs])
|
||||
if err != nil {
|
||||
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
|
||||
}
|
||||
env, err := actionStringMap(values[FieldEnv])
|
||||
if err != nil {
|
||||
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
|
||||
}
|
||||
return runtimeconfig.PluginConfig{
|
||||
Name: actionText(values, FieldServerName),
|
||||
Kind: runtimeconfig.PluginKindMCP,
|
||||
Transport: actionText(values, FieldTransport),
|
||||
Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")),
|
||||
Command: actionText(values, FieldCommand),
|
||||
Args: args,
|
||||
APIKey: actionText(values, FieldAPIKey),
|
||||
Env: env,
|
||||
}.Normalize(), nil
|
||||
}
|
||||
|
||||
// invalidConfigError 把一条「配置没写对」包成结构化动作失败(400)。
|
||||
//
|
||||
// 文案里只出现**键名与类型**,绝不回显值:env 的值可能就是凭据。
|
||||
func invalidConfigError(err error) error {
|
||||
return NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置没写对:%v", err), 400, err)
|
||||
}
|
||||
|
||||
// actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。
|
||||
func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) {
|
||||
name, err := actionRowName(req.Payload)
|
||||
@@ -146,6 +174,12 @@ func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any,
|
||||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||||
}
|
||||
m.log.Warn("mcp enable failed", "name", name, "error", err)
|
||||
// 两种失败的事实不同,文案也必须不同:连不上 ⇒ 它仍是停用的;**已经连上但启用态
|
||||
// 没落盘** ⇒ 现在能用、重启后会回到停用(说成「仍处于停用状态」就是与事实相反)。
|
||||
if errors.Is(err, ErrPluginStatePersist) {
|
||||
return nil, NewActionError(mcpCodeEnableFailed,
|
||||
fmt.Sprintf("%q 已连上,但启用态未落盘:重启后会回到停用,详细原因见后端日志", name), 502, err)
|
||||
}
|
||||
return nil, NewActionError(mcpCodeEnableFailed,
|
||||
fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err)
|
||||
}
|
||||
@@ -163,8 +197,9 @@ func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any,
|
||||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||||
}
|
||||
m.log.Warn("mcp disable failed", "name", name, "error", err)
|
||||
// 停用的落盘在**前面**(见 enabled.go):写不进去时 server 还在跑、工具还在。
|
||||
return nil, NewActionError(mcpCodeDisableFailed,
|
||||
fmt.Sprintf("停用 %q 失败:启用态未能落盘,详细原因见后端日志", name), 500, err)
|
||||
fmt.Sprintf("停用 %q 失败:启用态未能落盘,server 仍在运行(可再试一次),详细原因见后端日志", name), 500, err)
|
||||
}
|
||||
return map[string]any{"name": name, "enabled": false, "connected": false}, nil
|
||||
}
|
||||
@@ -259,51 +294,90 @@ func actionText(values map[string]any, key string) string {
|
||||
}
|
||||
|
||||
// actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是
|
||||
// `[]any`;元素非字符串即忽略,空串不保留)。空列表返回 nil(配置里就不写这个键)。
|
||||
func actionStringList(raw any) []string {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
if strs, okStr := raw.([]string); okStr {
|
||||
return compactStrings(strs)
|
||||
// `[]any`)。
|
||||
//
|
||||
// 形状错一律报错:非数组、元素非字符串都不许**静默丢弃**(少一个参数、或多出一个空参数,
|
||||
// 都会让 server 起来后行为不对,而面板还报「装好了」)。空项按前端 `splitListValue` 的同一
|
||||
// 口径去掉(那只来自多余的分隔符,不是用户意图)。
|
||||
func actionStringList(raw any) ([]string, error) {
|
||||
switch items := raw.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []string:
|
||||
return compactStrings(items), nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(items))
|
||||
for i, item := range items {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("args 的第 %d 项不是字符串(收到 %s);每个参数都必须是字符串", i+1, jsonKindOf(item))
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return nil
|
||||
return compactStrings(out), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("args 必须是字符串数组(收到 %s)", jsonKindOf(raw))
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return compactStrings(out)
|
||||
}
|
||||
|
||||
// actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。
|
||||
//
|
||||
// 值只接受字符串:`{"PORT":8080}`(数字)这类会被**拒之门外而不是静默转成 "8080"** ——
|
||||
// 静默转换正是「看着配好了、其实没生效」的来源。
|
||||
func actionStringMap(raw any) map[string]string {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(obj))
|
||||
for k, v := range obj {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
// 值**只接受字符串**,其它类型一律报错而不是丢掉那个键:`{"PORT":8080}` 是最自然的写法,
|
||||
// 静默丢掉它等于「装完提示成功、env 却没生效」(前端 `KvControl` 只校验「是个对象」,
|
||||
// 所以这条路 UI 天然可达)。空键同理 —— 丢一个键与拒一个键必须选后者。
|
||||
func actionStringMap(raw any) (map[string]string, error) {
|
||||
var pairs map[string]any
|
||||
switch obj := raw.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case map[string]any:
|
||||
pairs = obj
|
||||
case map[string]string:
|
||||
pairs = make(map[string]any, len(obj))
|
||||
for k, v := range obj {
|
||||
pairs[k] = v
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("env 必须是键值对对象(收到 %s)", jsonKindOf(raw))
|
||||
}
|
||||
out := make(map[string]string, len(pairs))
|
||||
for k, v := range pairs {
|
||||
key := strings.TrimSpace(k)
|
||||
if key == "" {
|
||||
continue
|
||||
return nil, fmt.Errorf("env 里有空键;键就是环境变量名,不能为空")
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("env.%s 的值不是字符串(收到 %s);要写数字/布尔请加引号,如 \"8080\"", key, jsonKindOf(v))
|
||||
}
|
||||
out[key] = s
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonKindOf 给出一个 JSON 值的中文类型名(只为错误文案:说清「收到的是什么」)。
|
||||
//
|
||||
// 它刻意不打印值本身:env 的值可能就是凭据。
|
||||
func jsonKindOf(v any) string {
|
||||
switch v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return "字符串"
|
||||
case bool:
|
||||
return "布尔"
|
||||
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return "数字"
|
||||
case []any:
|
||||
return "数组"
|
||||
case map[string]any:
|
||||
return "对象"
|
||||
default:
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compactStrings 去掉两端空白与空项;结果为空时返回 nil。
|
||||
|
||||
Reference in new issue
Block a user