From 2e1c3ed3fdf73bf6276b31eb76275cc1d8b281e3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 14:12:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E9=9D=A2=E6=9D=BF=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E6=89=A7=E8=A1=8C=E5=99=A8=E6=8E=A5=E7=BA=BF=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=8A=A8=E4=BD=9C=E7=9C=9F=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=EF=BC=8C=E4=B8=8D=E5=86=8D=20501=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=88L1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404 - web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/ testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600) - 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」 - 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行 缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback (按 DECISIONS-2026-10-06 #1/#2 补)。 --- cmd/cyrene/websearch_action_data.go | 413 ++++++++++++++++ cmd/cyrene/websearch_actions_config_test.go | 88 ++++ cmd/cyrene/websearch_probe.go | 117 +++++ cmd/cyrene/wire_frontend.go | 50 +- cmd/cyrene/wire_plugin_actions.go | 188 ++++++++ cmd/cyrene/wire_plugin_actions_test.go | 448 ++++++++++++++++++ internal/capability/plugin/actions.go | 190 ++++++++ internal/capability/plugin/actions_test.go | 220 +++++++++ internal/capability/plugin/panelactions.go | 190 ++++++++ internal/capability/plugin/panels.go | 164 +------ internal/capability/plugin/panels_test.go | 46 +- internal/services/gateway/admin_panels.go | 68 ++- .../services/gateway/admin_panels_test.go | 65 ++- 13 files changed, 2043 insertions(+), 204 deletions(-) create mode 100644 cmd/cyrene/websearch_action_data.go create mode 100644 cmd/cyrene/websearch_actions_config_test.go create mode 100644 cmd/cyrene/websearch_probe.go create mode 100644 cmd/cyrene/wire_plugin_actions.go create mode 100644 cmd/cyrene/wire_plugin_actions_test.go create mode 100644 internal/capability/plugin/actions.go create mode 100644 internal/capability/plugin/actions_test.go create mode 100644 internal/capability/plugin/panelactions.go diff --git a/cmd/cyrene/websearch_action_data.go b/cmd/cyrene/websearch_action_data.go new file mode 100644 index 0000000..6ce4204 --- /dev/null +++ b/cmd/cyrene/websearch_action_data.go @@ -0,0 +1,413 @@ +package main + +// 本文件是「搜索提供商」页**五个动作本体**的数据面(切片 L1/PLG-2 的执行面接线): +// 可变源的读写(含失败分类)、表格行 / 下拉候选 / 掩码回填的翻译、以及请求体形状的解析。 +// +// 上游事实(`plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / +// `plugin.reauthProvider` / `plugin.providers`)由组合根登记到插件管理器上,登记入口在 +// `wire_plugin_actions.go`,探针的传输层绑定在 `websearch_probe.go`。 +// +// 三条边界(与登记面同):只经 runtimeconfig 读写可变源;凭据只出掩码;形状不对就报错。 + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// --- 四个动作 + 一个候选来源 ------------------------------------------------- + +// listProviders 是表格区块的数据源:把可变源里的各条配置翻译成面板行 +// (provider / state / enabled;**凭据与地址都不进行数据**)。 +func (e webSearchActionEnv) listProviders(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { + f, err := e.loadFile() + if err != nil { + return nil, err + } + return map[string]any{"rows": providerRows(f)}, nil +} + +// listProviderOptions 给出下拉候选:本期全部已登记的 provider 名(含尚未配置的家)。 +// +// 给全部而不是「已配置的家」的理由见 search.ProviderOptions:表单的用途就是**新增** +// 一家,只列已配置的会让第一次配置无从选起。 +func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { + return map[string]any{"options": search.ProviderOptions()}, nil +} + +// saveProvider 新增 / 修改一条 provider 配置(表单提交,写动作)。 +// +// 三态写回(与前端 `schema/values.ts:planSubmit` 逐条同义): +// - apiKey **缺位** ⇒ 保留原值(面板只持有掩码,「留空 = 不改」必须是真的); +// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串); +// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。 +// +// 只接受**已登记**的名字:未登记的家存进去也永远不会被选中(注册表按名字取), +// 那会让「我明明配了 tavily」变成一句假话 —— 所以在这里就拒。 +func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) { + values, err := actionValuesObject(req.Payload) + if err != nil { + return nil, err + } + name, err := requiredProviderName(values) + if err != nil { + return nil, err + } + if err := requireKnownProvider(name); err != nil { + return nil, err + } + cur, err := e.loadFile() + if err != nil { + return nil, err + } + prev, existed := findProviderEntry(cur, name) + entry, err := mergeProviderEntry(prev, values) + if err != nil { + return nil, err + } + entry.Provider = name + if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(cur, entry)); err != nil { + e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err) + return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed, + "面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err) + } + e.log.Info("web-search provider saved from panel", + "path", e.path, "provider", name, "created", !existed, "enabled", entry.IsEnabled()) + return map[string]any{ + "saved": true, + "provider": name, + "created": !existed, + "enabled": entry.IsEnabled(), + }, nil +} + +// testProvider 对一条配置**真发一次最小检索**(行内动作「测试连通」)。 +// +// 凭据与地址都从可变源读(面板里刚存下的就是它),不从前端传上来的行数据读 +// —— 行数据里没有 key(列表列刻意不带凭据),也不该由客户端指定要探的目标。 +// +// 失败一律回**结构化非 2xx**:面板的行内动作只在失败时显示文案(成功分支只说 +// 「已完成」),把「探不通」放进 200 的 data 里等于让界面报一句假成功。 +func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.ActionRequest) (any, error) { + name, err := providerNameOf(req.Payload) + if err != nil { + return nil, err + } + if err := requireKnownProvider(name); err != nil { + return nil, err + } + f, err := e.loadFile() + if err != nil { + return nil, err + } + entry, _ := findProviderEntry(f, name) + base := e.effectiveBaseURL(name, entry) + target, err := newProbeTarget(base) + if err != nil { + // 地址不合法 / 为空 ⇒ 这条没配齐(探针自己也会这么判,但这里能给出更准的话)。 + return nil, pluginmgr.NewActionError(search.ProbeNotConfigured, + "这条没配齐:接口地址为空或不是 http(s) 绝对地址", http.StatusBadRequest, err) + } + cfg := search.Config{ + APIKey: entry.APIKey, + BaseURL: base, + Client: target.client(e.probeTimeout(f)), + MaxBytes: webSearchProbeMaxBytes, + Guard: target.guard, + } + res := search.Probe(ctx, name, cfg, 0) + if !res.OK { + e.log.Warn("web-search panel probe failed", + "provider", name, "code", res.ErrorCode, "latencyMs", res.LatencyMS) + return nil, pluginmgr.NewActionError(res.ErrorCode, res.Error, probeErrorStatus(res.ErrorCode), nil) + } + e.log.Info("web-search panel probe ok", "provider", name, "count", res.Count, "latencyMs", res.LatencyMS) + return res, nil +} + +// reauthProvider 把一条配置**带回表单**(行内动作「重配凭据」,design/09 §5.2.1 示例的 +// reauth 语义):返回 `{values:{...}}`,前端把它灌进唯一的 form 区块。 +// +// 两处口径: +// - apiKey 回**掩码**(读侧无明文,§5.4.2):前端对 secret 字段一律不回填输入框, +// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选; +// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次 +// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。 +func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) { + name, err := providerNameOf(req.Payload) + if err != nil { + return nil, err + } + if err := requireKnownProvider(name); err != nil { + return nil, err + } + f, err := e.loadFile() + if err != nil { + return nil, err + } + entry, exists := findProviderEntry(f, name) + values := map[string]any{ + "provider": name, + "baseUrl": entry.BaseURL, + "enabled": !exists || entry.IsEnabled(), + } + if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" { + values["apiKey"] = masked.APIKey + } + return map[string]any{"values": values}, nil +} + +// --- 可变源读写(含失败分类) ------------------------------------------------- + +// loadFile 读可变源。 +// +// 文件**缺失或为空** = 「还没配过」(启动期已由 `.env` 播种;极端情况下面板先于启动期 +// 被调用)⇒ 返回空文件,不是错误。 +// 文件**存在但不合法** ⇒ 结构化失败:绝不把损坏配置当成空配置 —— 那会让一次保存 +// 静默盖掉一份人工写坏但可救回的内容(与启动期的处置同向:回落但不覆盖)。 +func (e webSearchActionEnv) loadFile() (runtimeconfig.WebSearchFile, error) { + f, found, err := runtimeconfig.LoadWebSearch(e.path) + if err != nil { + e.log.Error("web-search panel config unreadable", "path", e.path, "error", err) + return runtimeconfig.WebSearchFile{}, pluginmgr.NewActionError(actionCodeConfigUnreadable, + "面板配置读不出来(文件损坏或权限不足),本次**没有**改动它;详见服务端日志", + http.StatusInternalServerError, err) + } + if !found { + return runtimeconfig.WebSearchFile{ + Version: runtimeconfig.WebSearchVersion, + Providers: []runtimeconfig.ProviderEntry{}, + }, nil + } + return f, nil +} + +// providerRows 把各条配置翻译成面板表格行(凭据与地址都不进行数据)。 +func providerRows(f runtimeconfig.WebSearchFile) []search.ProviderRow { + inputs := make([]search.ProviderInput, 0, len(f.Providers)) + for _, p := range f.Providers { + inputs = append(inputs, search.ProviderInput{ + Provider: p.Provider, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + Enabled: p.IsEnabled(), + }) + } + return search.BuildRows(inputs) +} + +// findProviderEntry 按名字取一条配置(大小写与首尾空白不敏感,与 +// UpsertProviderEntry 的匹配键同一口径)。 +func findProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) { + want := strings.ToLower(strings.TrimSpace(name)) + for _, p := range f.Providers { + if strings.EqualFold(strings.TrimSpace(p.Provider), want) { + return p, true + } + } + return runtimeconfig.ProviderEntry{}, false +} + +// maskedProviderEntry 取某条配置的**掩码副本**(复用 runtimeconfig 的唯一掩码出口, +// 不在这里另写一份 maskToken)。 +func maskedProviderEntry(f runtimeconfig.WebSearchFile, name string) (runtimeconfig.ProviderEntry, bool) { + return findProviderEntry(runtimeconfig.MaskedWebSearchFile(f), name) +} + +// effectiveBaseURL 给出一条配置**实际会用的**基地址:盘上有就用盘上的,没有就回落到 +// 代码默认值(各云家的厂商默认地址)。 +// +// searxng 不回落(空地址 = 关掉这家,回落会让「清空地址」变成假话)—— 与 +// `effectiveSearchConfig` 的同一条权威边界。 +func (e webSearchActionEnv) effectiveBaseURL(name string, entry runtimeconfig.ProviderEntry) string { + if base := strings.TrimSpace(entry.BaseURL); base != "" { + return base + } + if strings.EqualFold(name, search.NameSearXNG) { + return "" + } + return e.defaultBaseURL(name) +} + +// defaultBaseURL 取某家云 provider 的代码默认基地址(来自 `.env` 解析后的配置,未写该键 +// 时就是标签默认值)。名字表复用组合根既有那张(`searchProviderVector`),不新造一份。 +func (e webSearchActionEnv) defaultBaseURL(name string) string { + for _, p := range searchProviderVector(e.envCfg) { + if strings.EqualFold(p.Name, name) { + return strings.TrimSpace(p.BaseURL) + } + } + return "" +} + +// probeTimeout 给出一次探针的超时上限:面板文件的 timeout 优先(面板里刚存的就是它), +// 其次 `.env`/代码默认值,最后是包内兜底 —— 与工具运行时的裁决顺序一致。 +func (e webSearchActionEnv) probeTimeout(f runtimeconfig.WebSearchFile) time.Duration { + if d, err := f.TimeoutDuration(); err == nil && d > 0 { + return d + } + if e.envCfg.Timeout > 0 { + return e.envCfg.Timeout + } + return webSearchProbeTimeout +} + +// --- 载荷解析(形状不对就报错,不静默当空) ------------------------------------ + +// actionObject 取请求体的 JSON 对象。空体(nil)返回 (nil, nil):少数动作不需要参数。 +func actionObject(payload any) (map[string]any, error) { + if payload == nil { + return nil, nil + } + obj, ok := payload.(map[string]any) + if !ok { + return nil, invalidPayloadActionError("请求体必须是一个 JSON 对象") + } + return obj, nil +} + +// actionValuesObject 取 `{values:{...}}`(表单提交的形状,见前端 formState.useFormSubmit)。 +func actionValuesObject(payload any) (map[string]any, error) { + obj, err := actionObject(payload) + if err != nil { + return nil, err + } + if obj == nil { + return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})") + } + values, ok := obj["values"].(map[string]any) + if !ok { + return nil, invalidPayloadActionError("缺少 values 对象(表单提交的形状是 {values:{...}})") + } + return values, nil +} + +// providerNameOf 从载荷里取 provider 名(三种**显式**来源,不猜字段): +// 行内动作给 `{row:{provider}}`(前端 tableState),也可给 `{values:{provider}}` +// (与表单同一形状),或顶层直接给 `provider`。 +func providerNameOf(payload any) (string, error) { + obj, err := actionObject(payload) + if err != nil { + return "", err + } + if obj == nil { + return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider)") + } + for _, key := range []string{"row", "values", "provider"} { + switch v := obj[key].(type) { + case string: + if key == "provider" && strings.TrimSpace(v) != "" { + return normalizeProviderName(v), nil + } + case map[string]any: + if name := actionString(v, "provider"); name != "" { + return normalizeProviderName(name), nil + } + } + } + return "", invalidPayloadActionError("缺少 provider 名(行内动作给 row.provider,表单给 values.provider)") +} + +// requiredProviderName 取表单里的 provider 名(必填)。 +func requiredProviderName(values map[string]any) (string, error) { + name := actionString(values, "provider") + if name == "" { + return "", invalidPayloadActionError("provider 必填:请选择 / 填写要配置的提供商名") + } + return normalizeProviderName(name), nil +} + +// requireKnownProvider 拒掉本期没登记的名字(存了也永远不会被选中;探它只会得到 +// 「未登记」)。错误码与文案与 search 的探针口径一致,前端可按同一个码本地化。 +func requireKnownProvider(name string) error { + if search.Known(name) { + return nil + } + return pluginmgr.NewActionError(actionCodeUnknownProvider, + fmt.Sprintf("未登记的搜索提供商 %q(本期接入位:%s)", name, strings.Join(search.KnownNames(), ", ")), + http.StatusBadRequest, nil) +} + +// actionString 取一个字符串字段(去首尾空白;非字符串与空串都算「没给」)。 +func actionString(m map[string]any, key string) string { + s, ok := m[key].(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +// normalizeProviderName 归一 provider 名(小写 + 去空白),与注册表/可变源的匹配键同口径。 +func normalizeProviderName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// mergeProviderEntry 把表单补丁合并进一条配置(三态写回,见 saveProvider 的注释)。 +// enabled 给了非布尔值时**显式报错**:静默保留会让「我关了它」变成假话。 +func mergeProviderEntry(prev runtimeconfig.ProviderEntry, values map[string]any) (runtimeconfig.ProviderEntry, error) { + out := prev + if v, ok := values["apiKey"]; ok { + key, err := actionOptionalString(v, "apiKey") + if err != nil { + return runtimeconfig.ProviderEntry{}, err + } + out.APIKey = key + } + if v, ok := values["baseUrl"]; ok { + base, err := actionOptionalString(v, "baseUrl") + if err != nil { + return runtimeconfig.ProviderEntry{}, err + } + out.BaseURL = base + } + on, set, err := actionOptionalBool(values["enabled"], "enabled") + if err != nil { + return runtimeconfig.ProviderEntry{}, err + } + if set { + out.Enabled = runtimeconfig.EnableFlag(on) + } + return out, nil +} + +// actionOptionalString 校验一个可选字符串字段:缺位 ⇒ 空串(调用方按「未给」处理); +// 非字符串 ⇒ 报错(把类型错静默当成空串 = 一次保存悄悄清空了某个字段)。 +func actionOptionalString(v any, key string) (string, error) { + if v == nil { + return "", nil + } + s, ok := v.(string) + if !ok { + return "", invalidPayloadActionError(fmt.Sprintf("%s 必须是字符串", key)) + } + return strings.TrimSpace(s), nil +} + +// actionOptionalBool 解析可选开关:缺位 / nil / 空串 ⇒ 未改动;bool ⇒ 采用; +// 其它类型 ⇒ 报错(理由同 actionOptionalString)。 +func actionOptionalBool(v any, key string) (value bool, set bool, err error) { + switch raw := v.(type) { + case nil: + return false, false, nil + case bool: + return raw, true, nil + case string: + if strings.TrimSpace(raw) == "" { + return false, false, nil + } + return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key)) + default: + return false, false, invalidPayloadActionError(fmt.Sprintf("%s 必须是布尔值", key)) + } +} + +// invalidPayloadActionError 造一个 400 invalid_payload(形状错 = 调用方错,不是上游错)。 +func invalidPayloadActionError(message string) error { + return pluginmgr.NewActionError(pluginmgr.ActionCodeInvalidPayload, message, http.StatusBadRequest, nil) +} diff --git a/cmd/cyrene/websearch_actions_config_test.go b/cmd/cyrene/websearch_actions_config_test.go new file mode 100644 index 0000000..aa842ff --- /dev/null +++ b/cmd/cyrene/websearch_actions_config_test.go @@ -0,0 +1,88 @@ +package main + +// 本文件钉住搜索提供商动作面的**危险/边界结局**(切片 L1/PLG-2):损坏配置不许被覆盖、 +// 装配期要如实点名「声明了却没实现」的动作。它们与主用例集分开成文件的理由只有一条: +// `wire_plugin_actions_test.go` 已接近「文件 ≤500 行」的上限(AGENTS §10)。 + +import ( + "bytes" + "context" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// TestWebSearchActionConfigUnreadableIsReported 锁定「文件存在但不合法」的口径: +// 结构化失败(500 config_unreadable),**不覆盖**原文件 —— 损坏的配置可以人工救回。 +func TestWebSearchActionConfigUnreadableIsReported(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}` + wsWriteRawPanel(t, env.path, broken) + + _, err := env.dispatch(t, search.ActionListProviders, map[string]any{}) + ae, ok := pluginmgr.AsActionError(err) + if !ok || ae.Code != actionCodeConfigUnreadable || ae.Status != http.StatusInternalServerError { + t.Fatalf("err = %#v, want 500 config_unreadable", err) + } + if _, err := env.dispatch(t, search.ActionSaveProvider, map[string]any{ + "values": map[string]any{"provider": search.NameBocha, "apiKey": "sk-x"}, + }); err == nil { + t.Fatal("配置不合法时必须拒绝保存(否则会静默盖掉可救回的原文件)") + } + raw, rerr := os.ReadFile(env.path) + if rerr != nil { + t.Fatalf("read: %v", rerr) + } + if string(raw) != broken { + t.Fatalf("原文件被改动了:%q", raw) + } +} + +// TestWarnImpliedButMissingNamesGaps 锁定装配期的**核对**:声明里有、登记里没有的动作 +// 必须在日志里点名(否则用户只会看到一个点了回 501 的按钮)。这里直接覆盖核对函数本身。 +func TestWarnImpliedButMissingNamesGaps(t *testing.T) { + cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + b := bus.New() + defer b.Close() + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) + defer mgr.Close(context.Background()) + + var buf bytes.Buffer + log := logger.New(logger.Options{Out: &buf, Level: "debug"}) + wirePanelHub(&app{cfg: cfg, log: log, bus: b, plugins: mgr}) + hub := mgr.Panels() + + // 模拟「插件声明新增了一个动作,但宿主忘了给它接线」:补一条声明(Declare 是整体 + // 替换语义 ⇒ 原有四个动作 + 候选来源仍在),只多一个没人实现的名字。 + extra := sdk.Panel{ + ID: "extra", Title: "额外页", + Sections: []sdk.Section{{ + Type: sdk.SectionTable, ID: "t", Title: "额外表", + Source: &sdk.Source{Action: "plugin.notWired"}, + Columns: []sdk.Column{{Key: "k", Label: "K"}}, + }}, + } + if _, err := hub.Declare(search.PanelPluginID, append(search.AdminPanels(), extra)); err != nil { + t.Fatalf("Declare: %v", err) + } + warnImpliedButMissing(hub, log) + + out := buf.String() + if !strings.Contains(out, "plugin.notWired") || !strings.Contains(out, "no handler") { + t.Fatalf("声明了却没实现的动作必须被点名(否则只剩一个回 501 的按钮): %q", out) + } + // 已登记的动作不得被误报。 + if idx := strings.Index(out, "action=plugin.listProviders"); idx >= 0 && + strings.Contains(out[idx:], "no handler") { + t.Fatalf("已登记的动作被误报成缺处理器: %q", out) + } +} diff --git a/cmd/cyrene/websearch_probe.go b/cmd/cyrene/websearch_probe.go new file mode 100644 index 0000000..188a02f --- /dev/null +++ b/cmd/cyrene/websearch_probe.go @@ -0,0 +1,117 @@ +package main + +// 本文件是「搜索提供商」页**探针**的传输层绑定(切片 L1/PLG-2 的执行面接线的一部分): +// 探针要用的 HTTP 客户端、目标约束(同主机守卫)与失败状态映射。 +// 动作本体的语义在 `websearch_action_data.go`,登记面在 `wire_plugin_actions.go`。 + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// 探针的边界值。名字化的理由与 io 包同一套:不许裸魔法数字(项目规范 §10)。 +const ( + // webSearchProbeTimeout 是探针的兜底超时(面板文件与 `.env` 都没声明时用它)—— + // 取 io 工具集的默认 http 超时同值(15s),免得「面板测通了、工具超时」这种怪象。 + webSearchProbeTimeout = 15 * time.Second + // webSearchProbeMaxBytes 是探针响应体的读取上限(与 io 包的 http 响应上限同口径 1 MiB)。 + webSearchProbeMaxBytes = 1 << 20 + // webSearchProbeMaxRedirects 是探针允许的跳转次数(与 io 的 maxRedirects 同值)。 + webSearchProbeMaxRedirects = 3 +) + +// probeErrorStatus 按探针码给出 HTTP 状态:调用方/配置问题 ⇒ 400;上游或网络 ⇒ 502。 +// +// 它**不**改变探针的语义(no_results 仍然是 no_results,不是「成功」也不是「该家不可用」), +// 只是给前端一个非 2xx 的信号,好让面板把结论显示出来(见 testProvider 的注释)。 +func probeErrorStatus(code string) int { + switch code { + case search.ProbeUnknownProvider, search.ProbeNotConfigured: + return http.StatusBadRequest + default: + return http.StatusBadGateway + } +} + +// probeTarget 是一次探针的目标约束:**只允许打到配置里那台主机**。 +// +// 与 io 包 SSRF 守卫的关系(写清楚,免得被当成第二份安全策略):地址来自面板里存下的 +// provider 基地址 —— 它是**运维输入**(owner 通过 owner/admin 写动作落下的),不是模型 +// 输入。io 的守卫用「私网/回环黑名单 + 允许清单」,而那个守卫在 `plugins/tools/io` +// 内部未导出,本片**不改他人文件**(硬边界),因此这里用一条**更严**的规则替代: +// 探针只与配置主机通话,跨主机(含跨主机重定向)一律拒绝。 +// 缺口如实登记:两处策略不逐字同源(见报告 §未做)。 +type probeTarget struct { + // host 是配置主机名(小写、无端口)。 + host string +} + +// newProbeTarget 解析出配置主机;地址为空 / 非 http(s) / 没有主机名时返回错误。 +func newProbeTarget(baseURL string) (probeTarget, error) { + raw := strings.TrimSpace(baseURL) + if raw == "" { + return probeTarget{}, fmt.Errorf("web-search probe: empty base url") + } + u, err := url.Parse(raw) + if err != nil { + return probeTarget{}, fmt.Errorf("web-search probe: parse base url: %w", err) + } + if !isHTTPURL(u) { + return probeTarget{}, fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme) + } + host := strings.ToLower(u.Hostname()) + if host == "" { + return probeTarget{}, fmt.Errorf("web-search probe: base url has no host") + } + return probeTarget{host: host}, nil +} + +// client 造这次探针要用的 HTTP 客户端:超时上限 + 同主机的跳转策略。 +// 它是**每次调用新建**的(探针很少发生,不值得为它维护一份共享客户端与连接池); +// 也因此不牵动 http / web-fetch / web-search 共用的那份客户端与白名单。 +func (t probeTarget) client(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout, CheckRedirect: t.checkRedirect} +} + +// guard 是注入给 search.Config 的地址判定(发请求前对最终地址做一次判定)。 +func (t probeTarget) guard(_ context.Context, rawURL string) error { return t.check(rawURL) } + +// checkRedirect 是跳转策略:次数上限 + 每一跳都要过同一条判定。 +func (t probeTarget) checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= webSearchProbeMaxRedirects { + return fmt.Errorf("web-search probe: too many redirects") + } + if req.URL == nil { + return fmt.Errorf("web-search probe: nil redirect target") + } + return t.check(req.URL.String()) +} + +// check 判定一个地址能否访问:必须是 http(s) 且主机等于配置主机。 +func (t probeTarget) check(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("web-search probe: parse target: %w", err) + } + if !isHTTPURL(u) { + return fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme) + } + if !strings.EqualFold(u.Hostname(), t.host) { + return fmt.Errorf("web-search probe: refused cross-host target %q", u.Hostname()) + } + return nil +} + +// isHTTPURL 报告地址是否是可访问的 http(s) 绝对地址。 +func isHTTPURL(u *url.URL) bool { + if u == nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index b9ef56f..ddcb17c 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -69,24 +69,24 @@ func (a *app) wireFrontend() error { tidyDryRunner = a.memoryTidy } gw, err := gateway.New(a.cfg, gateway.Deps{ - Log: logger.WithSource(a.log, "gateway"), - Auth: jwt, - Bus: a.bus, - Registry: a.caps, - Loop: a.loop, - Session: a.sessions, - Personas: a.personas, - Memories: a.memStore, - History: a.history, - IoT: a.iot, - Scheduler: a.scheduler, - Tasks: a.tasks, - Automations: a.automation, - Users: apiKeys, - Refresh: refreshStore, - Backthink: a.backthink, - RAG: ragStore, - LLM: a.llm, + Log: logger.WithSource(a.log, "gateway"), + Auth: jwt, + Bus: a.bus, + Registry: a.caps, + Loop: a.loop, + Session: a.sessions, + Personas: a.personas, + Memories: a.memStore, + History: a.history, + IoT: a.iot, + Scheduler: a.scheduler, + Tasks: a.tasks, + Automations: a.automation, + Users: apiKeys, + Refresh: refreshStore, + Backthink: a.backthink, + RAG: ragStore, + LLM: a.llm, // EnvPath 与 main.go 启动时 config.Load(envPath) 用同一条路径:面板 // 「重置为 env 默认」(切片 R1)按既有解析入口重读同一份 `.env` 声明, // 网关自己不 fopen `.env`。 @@ -164,7 +164,9 @@ func (a *app) wireFrontend() error { } // wirePanelHub 给已构造的插件管理器接上面板声明中枢,并把它接到既有进程内 bus 的 -// `ws.panels_changed` 上(design/09 §5.2.1;切片 PLG-2)。 +// `ws.panels_changed` 上(design/09 §5.2.1;切片 PLG-2)。之后再把内置插件的**动作 +// 处理器**登记到管理器上(切片 L1/PLG-2 的执行面接线):没有这一步,面板的每个动作都 +// 只能诚实回 501(声明在、没人执行)。 // // 三条约束都在这里落地: // - **不新开端口**:热更新信号只走进程内 bus(hub 订阅 ws.> ⇒ 帧类型 panels_changed), @@ -177,7 +179,8 @@ func wirePanelHub(a *app) { if a.plugins == nil { return } - hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "capability/plugin/panels")) + log := logger.WithSource(a.log, "capability/plugin/panels") + hub, err := pluginmgr.NewPanels(log) if err != nil { a.log.Warn("panel hub unavailable, plugin panel endpoints will answer 501", "error", err) return @@ -194,7 +197,12 @@ func wirePanelHub(a *app) { } }) a.plugins.AttachPanels(hub) - declareBuiltinPanels(hub, logger.WithSource(a.log, "capability/plugin/panels")) + declareBuiltinPanels(hub, log) + // 登记动作处理器必须在**声明之后**:登记期会核对「登记的处理器有没有对应声明」, + // 顺序反过来只会得到一串「声明还没到」的 WARN。 + // 日志来源另标一处(动作接线 ≠ 面板中枢):`source=cmd/cyrene/wire_plugin_actions` + // 让「谁登记了哪些动作 / 哪些声明没人实现」能在日志里独立定位。 + wireBuiltinPluginActions(a.plugins, hub, a, logger.WithSource(a.log, actionWireLogSource)) } // declareBuiltinPanels 把**编译进二进制的内置插件**的面板声明交给中枢 diff --git a/cmd/cyrene/wire_plugin_actions.go b/cmd/cyrene/wire_plugin_actions.go new file mode 100644 index 0000000..aea35c7 --- /dev/null +++ b/cmd/cyrene/wire_plugin_actions.go @@ -0,0 +1,188 @@ +package main + +// 本文件是**内置插件动作面的登记入口**(design/09 §5.2.1「动作分发」;切片 L1/PLG-2 的 +// 执行面接线):把编译进二进制的插件声明的动作处理器登记到插件管理器上。首个使用者是 +// web-search 的「搜索提供商」页(§5.5 dogfood):四个动作 +// plugin.listProviders / plugin.saveProvider / plugin.testProvider / plugin.reauthProvider, +// 外加下拉候选来源 `plugin:providers`(前端按 `optionsFrom` 推出这个名字)。 +// +// 三个文件的分工(都在本包): +// - 本文件:登记入口、处理器环境、动作名映射、「声明了却没实现」的装配期核对; +// - `websearch_action_data.go`:五个动作的业务数据面(可变源的读写与翻译、载荷解析); +// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射)。 +// +// 三条边界: +// +// 1. **数据面只读写可变源**:`$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1), +// 经 runtimeconfig 的读写入口(0600 + temp+rename),宿主自己不开新路径、不猜形状; +// 2. **探针真发一次请求**:走 `search.Probe`(同一家 provider 构造、同一份归一化), +// 不靠「配置看起来齐了」假装连通(§5.5 的「测试连通」); +// 3. **凭据不出后端**:读侧只回掩码(`MaskedWebSearchFile`),日志只记 provider 名与 +// 结果码,绝不记 key / 地址。 +// +// 本片不做的事(如实登记,见报告 §未做):不热替换**运行中**的 web-search 工具 —— +// 面板保存后工具仍用启动期解析出的 provider 接线,改动在下次重启生效。 + +import ( + "log/slog" + "sort" + "strings" + + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// actionWireLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。 +const actionWireLogSource = "cmd/cyrene/wire_plugin_actions" + +// 动作失败码(宿主级,且只在动作面这组文件里使用)。领域自己的码来自 search(探针码) +// 与 pluginmgr.ActionCodeInvalidPayload。 +const ( + // actionCodeUnknownProvider 报告请求点名了一家本期没登记的 provider。 + actionCodeUnknownProvider = "unknown_provider" + // actionCodeConfigUnreadable 报告面板配置存在但读不出来(损坏/权限)。 + actionCodeConfigUnreadable = "config_unreadable" + // actionCodeConfigWriteFailed 报告面板配置写不进去(磁盘/权限)。 + actionCodeConfigWriteFailed = "config_write_failed" +) + +// wireBuiltinPluginActions 把内置插件的动作处理器登记到插件管理器上(§5.2.1: +// 插件在组合根注册自己的动作处理器)。 +// +// 登记之后旧账要结清:**声明里写了但没人实现**的动作会如实记一条 WARN(端点对它的 +// 结算就是 501)。这正是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的 +// 运行期版本 —— 装配期就把差异说出来,而不是等用户点出一个 501。 +// +// 登记失败(命名空间非法 / 中枢未接线)只记 ERROR、不阻断启动:一个坏的动作面不该 +// 让整个后端起不来(与面板声明的处置一致:该面板不注册 + 显式报错)。 +func wireBuiltinPluginActions(mgr *pluginmgr.Manager, hub *pluginmgr.Panels, a *app, log *slog.Logger) { + if mgr == nil || hub == nil { + return + } + env, ok := a.webSearchActionEnv(log) + if !ok { + log.Warn("builtin plugin actions not registered: no config/data dir", + "pluginId", search.PanelPluginID, "effect", "panel actions will answer 501") + return + } + handlers := webSearchActionHandlers(env) + for optionsFrom, handler := range webSearchOptionsHandlers(env) { + name := pluginmgr.OptionsActionName(optionsFrom) + if name == "" { + log.Error("optionsFrom 无法映射成动作名,候选来源未接线", + "pluginId", search.PanelPluginID, "optionsFrom", optionsFrom) + continue + } + handlers[name] = handler + } + if err := mgr.RegisterActions(search.PanelPluginID, handlers); err != nil { + log.Error("builtin plugin actions not registered; panel actions will answer 501", + "pluginId", search.PanelPluginID, "error", err) + return + } + log.Info("builtin plugin actions registered", + "pluginId", search.PanelPluginID, "actions", registeredActionNames(hub)) + warnImpliedButMissing(hub, log) +} + +// warnImpliedButMissing 把「声明里有、登记里没有」的动作与候选来源如实报出来 +// (前者的结算就是 501 action_not_implemented;后者会让前端静默降级成文本框)。 +func warnImpliedButMissing(hub *pluginmgr.Panels, log *slog.Logger) { + registered := make(map[string]bool) + for _, name := range hub.ActionHandlerNames(search.PanelPluginID) { + registered[name] = true + } + for _, name := range hub.DeclaredActions(search.PanelPluginID) { + if !registered[name] { + log.Warn("declared plugin action has no handler; it will answer 501", + "pluginId", search.PanelPluginID, "action", name) + } + } + for _, src := range declaredOptionsSources(search.AdminPanels()) { + name := pluginmgr.OptionsActionName(src) + if name == "" || !registered[name] { + log.Warn("declared optionsFrom has no handler; the control degrades to a text input", + "pluginId", search.PanelPluginID, "optionsFrom", src, "action", name) + } + } +} + +// registeredActionNames 返回已登记的处理器的动作名(诊断日志用,已排序)。 +func registeredActionNames(hub *pluginmgr.Panels) []string { + names := hub.ActionHandlerNames(search.PanelPluginID) + sort.Strings(names) + return names +} + +// declaredOptionsSources 从声明里收集全部 `optionsFrom` 取值(去重、排序)。 +// +// 用途是**反向核对**:声明里出现了一个没有实现的候选来源时,装配期就要说出来 —— +// 否则前端只会静默降级成文本框,没人知道是「声明多写了一个」。 +func declaredOptionsSources(panels []sdk.Panel) []string { + seen := make(map[string]bool) + for _, panel := range panels { + for _, s := range panel.Sections { + for _, f := range s.Fields { + if key := strings.TrimSpace(f.OptionsFrom); key != "" { + seen[key] = true + } + } + } + } + out := make([]string, 0, len(seen)) + for key := range seen { + out = append(out, key) + } + sort.Strings(out) + return out +} + +// webSearchActionEnv 是 web-search 动作处理器共享的接线事实(一次装配、多动作复用)。 +// +// 刻意只装**在多个动作间共享**的东西(日志出口、可变源路径、`.env` 解析后的配置), +// 每次调用各自读文件:动作之间不共享「当前配置」这份可变状态,就不会出现 +// 「A 动作读到的是 B 动作刚改之前的那份」。 +type webSearchActionEnv struct { + log *slog.Logger + // path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。 + path string + // envCfg 是 `.env` 解析结果(**只用来取代码默认值**:各云家的默认基地址与超时)。 + // 把面板没写的字段落回代码默认值,与工具运行时的 `orDefault` 口径一致。 + envCfg config.WebSearchConfig +} + +// webSearchActionEnv 构造处理器环境。ok=false 表示没有配置/数据目录 +// (未装配完成或测试夹具),此时不登记动作,由调用方如实告警。 +func (a *app) webSearchActionEnv(log *slog.Logger) (webSearchActionEnv, bool) { + if a.cfg == nil { + return webSearchActionEnv{}, false + } + return webSearchActionEnv{ + log: log, + path: runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg)), + envCfg: a.cfg.WebSearch, + }, true +} + +// webSearchActionHandlers 返回 web-search 插件声明的**四个动作**的处理器。 +func webSearchActionHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler { + return map[string]pluginmgr.ActionHandler{ + search.ActionListProviders: env.listProviders, + search.ActionSaveProvider: env.saveProvider, + search.ActionTestProvider: env.testProvider, + search.ActionReauthProvider: env.reauthProvider, + } +} + +// webSearchOptionsHandlers 返回「下拉候选来源」的处理器(键 = 声明里的 `optionsFrom` 取值)。 +// +// 映射成什么动作名由 `pluginmgr.OptionsActionName` 一处决定(与前端 +// `resolveOptionsAction` 同源):两处若分叉,声明的下拉就会去调一个宿主不认识的名字。 +func webSearchOptionsHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler { + return map[string]pluginmgr.ActionHandler{ + "plugin:providers": env.listProviderOptions, + } +} diff --git a/cmd/cyrene/wire_plugin_actions_test.go b/cmd/cyrene/wire_plugin_actions_test.go new file mode 100644 index 0000000..d0b8f50 --- /dev/null +++ b/cmd/cyrene/wire_plugin_actions_test.go @@ -0,0 +1,448 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "sort" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// 本文件钉住切片 L1/PLG-2 的**执行面接线**:内置插件 web-search 的四个动作 + +// 下拉候选来源真的被登记到插件管理器上,并且真的能读写可变源、真发一次探针请求。 +// +// 为什么在组合根测:处理器与可变源的**接线事实**(哪个文件、哪份默认值、哪个客户端) +// 只在这里成立;插件管理器的单测只能证明「登记面会路由」,证明不了「搜索页点得动」。 + +// actionTestEnv 是一个接好面板中枢 + 动作处理器的组合根夹具。 +type actionTestEnv struct { + app *app + mgr *pluginmgr.Manager + hub *pluginmgr.Panels + // path 是可变源落点($DATA_DIR/config/plugins/web-search.json)。 + path string +} + +// newActionTestEnv 按组合根的**同一条路**接线:wirePanelHub(声明 + 动作处理器登记)。 +// extra 是额外的 .env 行,用来控制代码默认值(如 WEB_SEARCH_PROVIDER)。 +func newActionTestEnv(t *testing.T, extra ...string) *actionTestEnv { + t.Helper() + cfg := wsLoadConfig(t, extra...) + b := bus.New() + t.Cleanup(func() { _ = b.Close() }) + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) + t.Cleanup(func() { mgr.Close(context.Background()) }) + + a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr} + wirePanelHub(a) + hub := mgr.Panels() + if hub == nil { + t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上") + } + return &actionTestEnv{app: a, mgr: mgr, hub: hub, path: runtimeconfig.WebSearchConfigPath(cfg.DataDir)} +} + +// dispatch 调一次动作(走生产同一条 Dispatch:声明 → 启用 → 处理器)。 +func (e *actionTestEnv) dispatch(t *testing.T, action string, payload any) (any, error) { + t.Helper() + return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, + Action: action, + Payload: payload, + }) +} + +// mustDispatch 调一次动作并要求成功。 +func (e *actionTestEnv) mustDispatch(t *testing.T, action string, payload any) any { + t.Helper() + out, err := e.dispatch(t, action, payload) + if err != nil { + t.Fatalf("dispatch %s: %v", action, err) + } + return out +} + +// saveProvider 走保存动作(表单形状 `{values:{...}}`)。 +func (e *actionTestEnv) saveProvider(t *testing.T, values map[string]any) any { + t.Helper() + return e.mustDispatch(t, search.ActionSaveProvider, map[string]any{"values": values}) +} + +// rowsOf 把列表动作的结果解成面板行。 +func (e *actionTestEnv) rowsOf(t *testing.T) []search.ProviderRow { + t.Helper() + out := e.mustDispatch(t, search.ActionListProviders, map[string]any{}) + m, ok := out.(map[string]any) + if !ok { + t.Fatalf("列表结果类型 = %T", out) + } + rows, ok := m["rows"].([]search.ProviderRow) + if !ok { + t.Fatalf("列表结果缺 rows: %#v", m) + } + return rows +} + +// rowOf 取某一家在表格里的那一行(找不到则失败)。 +func (e *actionTestEnv) rowOf(t *testing.T, provider string) search.ProviderRow { + t.Helper() + for _, r := range e.rowsOf(t) { + if r.Provider == provider { + return r + } + } + t.Fatalf("表格里没有 %q 这一行(rows = %+v)", provider, e.rowsOf(t)) + return search.ProviderRow{} +} + +// loadFile 读盘上的可变源(断言「真的落盘了」时用)。 +func (e *actionTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile { + t.Helper() + f, found, err := runtimeconfig.LoadWebSearch(e.path) + if err != nil { + t.Fatalf("LoadWebSearch(%s): %v", e.path, err) + } + if !found { + t.Fatalf("可变源不在盘上: %s", e.path) + } + return f +} + +// entryOf 取盘上某家的条目。 +func (e *actionTestEnv) entryOf(t *testing.T, provider string) runtimeconfig.ProviderEntry { + t.Helper() + for _, p := range e.loadFile(t).Providers { + if p.Provider == provider { + return p + } + } + t.Fatalf("盘上没有 %q 条目", provider) + return runtimeconfig.ProviderEntry{} +} + +// TestWirePanelHubRegistersBuiltinActions 是接线的主判据:内置插件的四个动作与 +// 下拉候选来源都进了登记面,且每一个都能真的被 Dispatch 执行(不再回 501)。 +func TestWirePanelHubRegistersBuiltinActions(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + + want := append([]string(nil), search.ActionNames()...) + want = append(want, pluginmgr.OptionsActionName("plugin:providers")) + registered := env.hub.ActionHandlerNames(search.PanelPluginID) + sort.Strings(want) + if strings.Join(registered, ",") != strings.Join(want, ",") { + t.Fatalf("已登记的处理器 = %v, want %v", registered, want) + } + // 每个已登记的动作都必须**已声明**(否则登记它等于登记一个永远调不到的名字)。 + for _, name := range registered { + if _, ok := env.hub.Action(search.PanelPluginID, name); !ok { + t.Errorf("动作 %q 已登记但没声明(永远不可达)", name) + } + } + // 声明里的每一个动作都必须有处理器 —— 否则面板上会有一个点了回 501 的按钮。 + for _, name := range env.hub.DeclaredActions(search.PanelPluginID) { + if _, err := env.dispatch(t, name, nil); errors.Is(err, pluginmgr.ErrActionUnavailable) { + t.Errorf("声明过的动作 %q 没有处理器(会回 501)", name) + } + } + // 列表动作也必须真的执行(此时可变源还没配过 ⇒ 空表不是错误)。 + if rows := env.rowsOf(t); len(rows) != 0 { + t.Fatalf("没配过任何一家时列表应为空,实际 %+v", rows) + } +} + +// TestWebSearchListProvidersShowsStoredEntries 锁定列表动作读的是**可变源** +// (而不是种子/默认值):写进盘里的条目与状态都如实出现,且不含任何凭据。 +func TestWebSearchListProvidersShowsStoredEntries(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + env.saveProvider(t, map[string]any{ + "provider": search.NameBocha, "apiKey": "sk-bocha-secret", + "baseUrl": "https://api.bocha.cn", "enabled": true, + }) + + rows := env.rowsOf(t) + byName := make(map[string]search.ProviderRow, len(rows)) + for _, r := range rows { + byName[r.Provider] = r + } + if got := byName[search.NameBocha]; got.State != search.StateOK || !got.Enabled { + t.Fatalf("bocha 行 = %+v, want state=ok 且启用", got) + } + raw, err := json.Marshal(rows) + if err != nil { + t.Fatalf("marshal rows: %v", err) + } + if strings.Contains(string(raw), "sk-bocha-secret") { + t.Fatalf("列表动作泄漏了凭据: %s", raw) + } +} + +// TestWebSearchSaveProviderHonoursSecretSentinel 锁定写侧的三态哨兵语义 +// (§5.4.2:空字符串 ≠ 不改动): +// - apiKey 缺位 ⇒ 原值保留(面板只持有掩码,留空必须真的不改); +// - apiKey 显式空串 ⇒ 清空; +// - enabled / baseUrl 缺位 ⇒ 保留。 +func TestWebSearchSaveProviderHonoursSecretSentinel(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + env.saveProvider(t, map[string]any{ + "provider": search.NameBocha, "apiKey": "sk-first", + "baseUrl": "https://api.bocha.cn", "enabled": true, + }) + + // 只改地址(apiKey / enabled 缺位)⇒ 凭据与启用态一个都不许动。 + env.saveProvider(t, map[string]any{"provider": search.NameBocha, "baseUrl": "https://mirror.bocha.cn"}) + got := env.entryOf(t, search.NameBocha) + if got.APIKey != "sk-first" { + t.Fatalf("apiKey 缺位时被改动了: %q(哨兵语义失效 = 一次保存静默清空凭据)", got.APIKey) + } + if got.BaseURL != "https://mirror.bocha.cn" || !got.IsEnabled() { + t.Fatalf("条目 = %+v, want 新地址 + 仍启用", got) + } + + // 显式停用 + 显式清空凭据(前端只有勾了清除才会送出空串)。 + env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": "", "enabled": false}) + got = env.entryOf(t, search.NameBocha) + if got.APIKey != "" { + t.Fatalf("显式空串必须清空凭据,得到 %q", got.APIKey) + } + if got.IsEnabled() { + t.Fatal("显式 enabled=false 必须落盘(停用态)") + } + if state := env.rowOf(t, search.NameBocha).State; state != search.StateDisabled { + t.Fatalf("停用后 state = %q, want %q", state, search.StateDisabled) + } +} + +// TestWebSearchReauthProviderReturnsMaskedValues 锁定 reauth「把该条带回表单」: +// 返回显式 values(前端 formValuesOfResult 只认 values/formValues), +// 且 apiKey 只给**掩码** —— 读侧永不回明文(§5.4.2)。 +func TestWebSearchReauthProviderReturnsMaskedValues(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + env.saveProvider(t, map[string]any{ + "provider": search.NameBocha, "apiKey": "sk-abcdefgh", + "baseUrl": "https://api.bocha.cn", "enabled": true, + }) + + out := env.mustDispatch(t, search.ActionReauthProvider, map[string]any{ + "row": map[string]any{"provider": search.NameBocha, "state": search.StateOK}, + }) + m, ok := out.(map[string]any) + if !ok { + t.Fatalf("reauth 结果类型 = %T", out) + } + values, ok := m["values"].(map[string]any) + if !ok { + t.Fatalf("reauth 结果缺 values: %#v", m) + } + if values["provider"] != search.NameBocha || values["baseUrl"] != "https://api.bocha.cn" { + t.Fatalf("回填的字段 = %#v", values) + } + if values["enabled"] != true { + t.Fatalf("enabled 回填 = %#v, want true", values["enabled"]) + } + masked, _ := values["apiKey"].(string) + if masked == "" || masked == "sk-abcdefgh" { + t.Fatalf("apiKey 回填 = %q,必须是掩码(读侧无明文)", masked) + } + if !strings.HasSuffix(masked, "efgh") { + t.Fatalf("掩码口径应与宿主的 maskToken 同源(尾 4 位),得到 %q", masked) + } +} + +// TestWebSearchTestProviderHitsRealHTTP 是探针的判据:**真发一次请求**到盘上那条 +// 配置指的地址,用真应答区分「通了」与「没通」。 +func TestWebSearchTestProviderHitsRealHTTP(t *testing.T) { + hits := make(chan *http.Request, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits <- r + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) + })) + defer srv.Close() + + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + env.saveProvider(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": srv.URL + "/search", "enabled": true, + }) + + out := env.mustDispatch(t, search.ActionTestProvider, map[string]any{ + "row": map[string]any{"provider": search.NameSearXNG}, + }) + res, ok := out.(search.ProbeResult) + if !ok { + t.Fatalf("探针结果类型 = %T", out) + } + if !res.OK || res.Count != 1 { + t.Fatalf("探针结论 = %+v, want ok 且 1 条", res) + } + select { + case r := <-hits: + if r.URL.Path != "/search" || !strings.Contains(r.URL.RawQuery, "q=cyrene") { + t.Fatalf("上游收到的请求 = %s?%s(探针必须真的搜一次)", r.URL.Path, r.URL.RawQuery) + } + default: + t.Fatal("上游一个请求都没收到 —— 探针没真发请求") + } +} + +// TestWebSearchTestProviderReportsFailureHonestly 锁定失败口径:上游拒了凭据 ⇒ +// 结构化非 2xx(码 = 探针码,文案固定不含上游原文),绝不回一句假的「已完成」。 +func TestWebSearchTestProviderReportsFailureHonestly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad key sk-should-not-leak"}`)) + })) + defer srv.Close() + + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + env.saveProvider(t, map[string]any{ + "provider": search.NameSearXNG, "baseUrl": srv.URL, "enabled": true, + }) + + _, err := env.dispatch(t, search.ActionTestProvider, map[string]any{ + "row": map[string]any{"provider": search.NameSearXNG}, + }) + ae, ok := pluginmgr.AsActionError(err) + if !ok { + t.Fatalf("探针失败 err = %v,期望结构化 ActionError", err) + } + if ae.Code != search.ProbeUnauthorized { + t.Fatalf("失败码 = %q, want %q", ae.Code, search.ProbeUnauthorized) + } + if ae.Status != http.StatusBadGateway { + t.Fatalf("失败状态 = %d, want 502(上游失败)", ae.Status) + } + if strings.Contains(ae.Message, "sk-should-not-leak") || ae.Message == "" { + t.Fatalf("失败文案 = %q(不得含上游原文,也不得为空)", ae.Message) + } +} + +// TestWebSearchTestProviderNotConfiguredIsClientError 锁定「没配齐」归 400: +// 这是调用方/配置的问题,不是上游不可用(前端据此区分该改配置还是该重试)。 +func TestWebSearchTestProviderNotConfiguredIsClientError(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + // 存一条**没配齐**的(显式清空 key):探针必须在发请求**之前**就报 not_configured, + // 绝不拿一个没有凭据的配置去戳上游。 + env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": ""}) + + _, err := env.dispatch(t, search.ActionTestProvider, map[string]any{ + "row": map[string]any{"provider": search.NameBocha}, + }) + ae, ok := pluginmgr.AsActionError(err) + if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest { + t.Fatalf("err = %#v, want 400 not_configured", err) + } +} + +// TestWebSearchActionsRejectUnknownProviderAndBadPayload 锁定动作的两类调用方错误 +// 都被**显式拒绝**(不静默存一条永远选不中的配置,也不把类型错当成空值)。 +func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + + cases := []struct { + name string + action string + payload any + wantCode string + }{ + {"保存未登记的家", search.ActionSaveProvider, + map[string]any{"values": map[string]any{"provider": "tavily", "apiKey": "sk-x"}}, actionCodeUnknownProvider}, + {"保存缺 provider", search.ActionSaveProvider, + map[string]any{"values": map[string]any{"apiKey": "sk-x"}}, pluginmgr.ActionCodeInvalidPayload}, + {"保存缺 values 对象", search.ActionSaveProvider, + map[string]any{"apiKey": "sk-x"}, pluginmgr.ActionCodeInvalidPayload}, + {"保存 enabled 类型错", search.ActionSaveProvider, + map[string]any{"values": map[string]any{"provider": search.NameBocha, "enabled": "yes"}}, pluginmgr.ActionCodeInvalidPayload}, + {"探针未登记的家", search.ActionTestProvider, + map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider}, + {"探针缺 provider", search.ActionTestProvider, map[string]any{}, pluginmgr.ActionCodeInvalidPayload}, + {"reauth 未登记的家", search.ActionReauthProvider, + map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + _, err := env.dispatch(t, tc.action, tc.payload) + ae, ok := pluginmgr.AsActionError(err) + if !ok { + t.Fatalf("err = %v,期望结构化 ActionError", err) + } + if ae.Code != tc.wantCode || ae.Status != http.StatusBadRequest { + t.Fatalf("err = %+v, want code=%q status=400", ae, tc.wantCode) + } + }) + } + // 被拒的保存不得在盘上留下痕迹(没写成功时文件可能根本不存在 —— 那也是「没留下」)。 + if f, found, err := runtimeconfig.LoadWebSearch(env.path); err == nil && found { + for _, p := range f.Providers { + if p.Provider == "tavily" { + t.Fatal("未登记的家被写进了可变源") + } + } + } +} + +// TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」: +// 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。 +func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) { + env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + + sources := declaredOptionsSources(search.AdminPanels()) + if len(sources) == 0 { + t.Fatal("声明里一个 optionsFrom 都没有:这条判据失去了意义") + } + for _, src := range sources { + name := pluginmgr.OptionsActionName(src) + if name == "" { + t.Fatalf("optionsFrom %q 映射不出动作名", src) + } + if _, ok := env.hub.Action(search.PanelPluginID, name); !ok { + t.Errorf("候选来源 %q(动作 %s)没进声明索引", src, name) + } + out, err := env.dispatch(t, name, map[string]any{}) + if err != nil { + t.Errorf("候选来源 %q 调不动: %v", src, err) + continue + } + options, _ := out.(map[string]any)["options"].([]string) + if len(options) == 0 { + t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src) + } + if strings.Join(options, ",") != strings.Join(search.ProviderOptions(), ",") { + t.Errorf("候选 = %v, want %v", options, search.ProviderOptions()) + } + } +} + +// TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成) +// 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable), +// 绝不假装成功地回一份空列表。 +func TestWebSearchActionsUnwiredAnswers501(t *testing.T) { + cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + b := bus.New() + defer b.Close() + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) + defer mgr.Close(context.Background()) + + // cfg 为 nil ⇒ 组合根拿不到数据目录,动作登记被跳过(只告警)。 + wirePanelHub(&app{log: testLog(), bus: b, plugins: mgr}) + hub := mgr.Panels() + if got := hub.ActionHandlerNames(search.PanelPluginID); len(got) != 0 { + t.Fatalf("没有配置时不该登记任何处理器,实际 %v", got) + } + _, err := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ + PluginID: search.PanelPluginID, Action: search.ActionListProviders, + }) + if !errors.Is(err, pluginmgr.ErrActionUnavailable) { + t.Fatalf("err = %v, want ErrActionUnavailable(未登记处理器必须诚实回 501)", err) + } +} diff --git a/internal/capability/plugin/actions.go b/internal/capability/plugin/actions.go new file mode 100644 index 0000000..fae4418 --- /dev/null +++ b/internal/capability/plugin/actions.go @@ -0,0 +1,190 @@ +package plugin + +// 本文件是 design/09-插件生态 §5.2.1「动作分发」一栏在**插件管理器侧的落点**(切片 +// L1/PLG-2 的执行面接线):动作处理器的**注册**(谁执行哪个动作)与失败的**结构化形状**。 +// +// 三条口径: +// +// 1. **注册面而不是全局执行器**(§5.2.1 B「插件管理器 = 唯一中枢」):插件在组合根把 +// 自己的动作处理器登记到插件管理器上;登记是可查的(谁登记了什么),未登记的插件 +// 互不干扰 —— 用一个全局 ActionExecutor 会让「哪个插件负责这个名字」变成不可证的约定。 +// 2. **声明仍是唯一闸门**:登记处理器**不**等于可执行。Dispatch 先查声明(`Action`), +// 未声明一律 404 —— 处理器的存在不会让一个没声明的名字变得可调用(纵深不变)。 +// 3. **失败要能分类**:ActionError 把「错误码 / 文案 / HTTP 状态」一起交出去 +// (§5.4.6 错误码与文案分离,前端据此本地化),绝不靠解析字符串把病因猜出来。 + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "strings" +) + +// ActionHandler 执行一个插件的**已声明动作**。 +// +// 与调用方的分工:宿主端点(网关)负责取调用者身份、角色门与审计;登记进来的处理器 +// 只负责「这个名字要做什么」。处理器**不得**去发起插件提供的任意 URL(§5.4.4:动作面 +// 只有名字),也不得把凭据写进返回值或日志(§5.4.2)。 +type ActionHandler func(ctx context.Context, req ActionRequest) (any, error) + +// ErrInvalidActionRegistration 报告一次动作处理器登记本身不合法(命名空间/名字/处理器)。 +// 它是**接线错误**(组合根写错了),不是运行期错误:调用方应当把它当成启动期问题处理。 +var ErrInvalidActionRegistration = errors.New("plugin: invalid action registration") + +// ActionCodeInvalidPayload 是宿主级的通用失败码:请求体形状不对(缺字段 / 类型错)。 +// 领域自己的失败码(如搜索的 unknown_provider)由插件实现定义,不集中在这里。 +const ActionCodeInvalidPayload = "invalid_payload" + +// ActionError 是动作处理器交出的**结构化失败**(错误码 + 文案 + 目标 HTTP 状态)。 +// +// 为什么由插件侧给出状态码:只有实现知道这次失败是「调用方写错了请求」(400)、 +// 「上游不可用」(502)还是「宿主没接线」(501,由哨兵错误表达)。网关只做透传, +// 不替实现猜病因(§5.4.6)。 +type ActionError struct { + // Code 是稳定的机器可读错误码(前端按它本地化,不解析文案)。 + Code string + // Message 是给人看的一句话(**绝不含凭据、绝不含上游原始报文**)。 + Message string + // Status 是这次失败该用的 HTTP 状态;0 表示由网关按默认(502)处理。 + Status int + // Err 是底层病因(可选)。它只进服务端日志,不进响应体。 + Err error +} + +// NewActionError 构造一个结构化动作失败。 +func NewActionError(code, message string, status int, err error) *ActionError { + return &ActionError{Code: code, Message: message, Status: status, Err: err} +} + +// Error 实现 error。格式把码、文案与病因串起来,只为日志可读; +// 客户端拿到的是 Code/Message 两个字段(不解析这句话)。 +func (e *ActionError) Error() string { + if e == nil { + return "" + } + if e.Err != nil { + return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Unwrap 让 errors.Is/As 能穿到病因(例如 runtimeconfig.ErrInvalidWebSearch)。 +func (e *ActionError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// AsActionError 取出一条错误链里的结构化动作失败(没有则 false)。 +// 网关用它把「插件侧自己说清了病因」与「未知的内部错误」分开映射。 +func AsActionError(err error) (*ActionError, bool) { + var ae *ActionError + if !errors.As(err, &ae) || ae == nil { + return nil, false + } + return ae, true +} + +// RegisterActions 为一个插件登记它的动作处理器(覆盖同名)。 +// +// pluginID 必须能当面板目录键(`PanelIDValid`):动作的命名空间就是面板目录的命名空间, +// 一个没有合法键的插件不可能被端点寻址到,登记它就是登记一个永远调不到的名字。 +// +// 名字为空 / 处理器为 nil 一律拒(静默忽略会让「点了没反应」重新变成一个查不出的谜)。 +// 首次调用会整体替换该插件的处理器表,之后再次调用是**前缀合并**(同名覆盖)—— +// 插件可以分两步登记自己的动作面。 +// +// 允许登记一个**当前还没声明**的动作名:它暂时不可达(Dispatch 先查声明), +// 但会记一条 WARN 让「登记了却永远不会触发」当场可见。声明与登记的先后顺序在 +// 组合根里不总是可控(热装插件的声明来自 manifest,可能晚于处理器注册)。 +func (p *Panels) RegisterActions(pluginID string, handlers map[string]ActionHandler) error { + if p == nil { + return fmt.Errorf("plugin: nil panel hub") + } + pluginID = strings.TrimSpace(pluginID) + if !PanelIDValid(pluginID) { + return fmt.Errorf("%w: %q 不能当面板目录键(动作没有所属命名空间)", + ErrInvalidActionRegistration, pluginID) + } + if len(handlers) == 0 { + return fmt.Errorf("%w: %s 没有给出任何处理器", ErrInvalidActionRegistration, pluginID) + } + clean := make(map[string]ActionHandler, len(handlers)) + for name, h := range handlers { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("%w: %s 里有空动作名", ErrInvalidActionRegistration, pluginID) + } + if h == nil { + return fmt.Errorf("%w: %s/%s 的处理器是 nil", ErrInvalidActionRegistration, pluginID, name) + } + clean[name] = h + } + + p.mu.Lock() + if p.handlers == nil { + p.handlers = make(map[string]map[string]ActionHandler) + } + if p.handlers[pluginID] == nil { + p.handlers[pluginID] = make(map[string]ActionHandler, len(clean)) + } + declared := actionIndex(p.decls[pluginID]) + for name, h := range clean { + p.handlers[pluginID][name] = h + } + p.mu.Unlock() + + warnUndeclaredHandlers(p.log, pluginID, clean, declared) + return nil +} + +// warnUndeclaredHandlers 对「登记了但当前没有声明」的动作记一条 WARN(见 RegisterActions)。 +// 名字排序后再报:日志顺序稳定,测试与排查都不靠 map 迭代顺序。 +func warnUndeclaredHandlers(log *slog.Logger, pluginID string, handlers map[string]ActionHandler, declared map[string]ActionKind) { + missing := make([]string, 0, len(handlers)) + for name := range handlers { + if _, ok := declared[name]; !ok { + missing = append(missing, name) + } + } + sort.Strings(missing) + for _, name := range missing { + log.Warn("action handler has no declaration yet; it stays unreachable until declared", + "pluginId", pluginID, "action", name) + } +} + +// RegisterActions 是 *Manager 上的动作登记入口(§5.2.1「插件在组合根注册自己的动作 +// 处理器」):它把登记转交给已接线的面板中枢,中枢未接线时报错 —— 绝不静默吞掉, +// 否则组合根会以为「动作接好了」而端点仍在 501。 +func (m *Manager) RegisterActions(pluginID string, handlers map[string]ActionHandler) error { + if m == nil { + return fmt.Errorf("plugin: nil manager") + } + hub := m.Panels() + if hub == nil { + return fmt.Errorf("%w: %s(面板中枢未接线,动作登记无处落)", + ErrInvalidActionRegistration, pluginID) + } + return hub.RegisterActions(pluginID, handlers) +} + +// ActionHandlerNames 返回某插件**已登记处理器**的动作名(已排序,面向诊断与测试)。 +// 它与 DeclaredActions 是两个不同的事实:前者是「谁能执行」,后者是「声明里有什么」。 +// 两者的交集才是真正可调用的动作(Dispatch 的口径)。 +func (p *Panels) ActionHandlerNames(pluginID string) []string { + if p == nil { + return nil + } + p.mu.RLock() + defer p.mu.RUnlock() + out := make([]string, 0, len(p.handlers[pluginID])) + for name := range p.handlers[pluginID] { + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/internal/capability/plugin/actions_test.go b/internal/capability/plugin/actions_test.go new file mode 100644 index 0000000..854b0b5 --- /dev/null +++ b/internal/capability/plugin/actions_test.go @@ -0,0 +1,220 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件锁定切片 L1/PLG-2 的**执行面接线**(design/09 §5.2.1 动作分发): +// - 登记面按 (pluginId, action) 精确路由,一个插件登记的处理器不替另一个插件作答; +// - 登记**不**放宽声明闸门:没声明的名字即使有处理器也 404; +// - 未登记处理器仍诚实回 ErrActionUnavailable(501),绝不假装执行成功; +// - 登记本身的错误(空命名空间 / 空名字 / nil 处理器)当场拒,不静默半登记。 + +// registerPanel 给 id 登记一份最简声明(一个读来源动作 + 一个写动作)。 +func registerPanel(t *testing.T, hub *Panels, pluginID string, readAction, writeAction string) { + t.Helper() + enabled := true + panel := sdk.Panel{ + ID: "p", Title: "P", Order: 1, + When: &sdk.When{Enabled: &enabled}, + Sections: []sdk.Section{ + {Type: sdk.SectionForm, ID: "f", Title: "F", + Fields: []sdk.Field{{Key: "k", Type: sdk.FieldText}}, + Submit: &sdk.Submit{Action: writeAction}}, + {Type: sdk.SectionTable, ID: "t", Title: "T", + Source: &sdk.Source{Action: readAction}, + Columns: []sdk.Column{{Key: "k"}}}, + }, + } + if _, err := hub.Declare(pluginID, []sdk.Panel{panel}); err != nil { + t.Fatalf("Declare(%s): %v", pluginID, err) + } +} + +// TestPanels_RegisterActionsRoutesByPluginAndAction 锁定登记面的精确路由: +// 同名动作在两个插件下各走各的处理器,返回结果互不串台。 +func TestPanels_RegisterActionsRoutesByPluginAndAction(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider") + registerPanel(t, hub, "beta", "plugin.listProviders", "plugin.saveProvider") + + for _, id := range []string{"alpha", "beta"} { + id := id + if err := hub.RegisterActions(id, map[string]ActionHandler{ + "plugin.listProviders": func(_ context.Context, r ActionRequest) (any, error) { + return "from-" + r.PluginID, nil + }, + }); err != nil { + t.Fatalf("RegisterActions(%s): %v", id, err) + } + } + for _, id := range []string{"alpha", "beta"} { + got, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: id, Action: "plugin.listProviders"}) + if err != nil { + t.Fatalf("Dispatch(%s): %v", id, err) + } + if got != "from-"+id { + t.Fatalf("Dispatch(%s) = %v,期望走自己的处理器", id, got) + } + } + // 只登记了读动作:同插件的写动作仍未接线(501),不因「这个插件登记过东西」而放行。 + if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"}); !errors.Is(err, ErrActionUnavailable) { + t.Fatalf("未单独登记的动作 err = %v,期望 ErrActionUnavailable", err) + } +} + +// TestPanels_RegisteredHandlerCannotBypassDeclaration 锁定登记的从属地位: +// 处理器存在但动作**没声明** ⇒ 仍然 404(且处理器一次都不跑)。 +func TestPanels_RegisteredHandlerCannotBypassDeclaration(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider") + + ran := 0 + if err := hub.RegisterActions("alpha", map[string]ActionHandler{ + "plugin.nukeEverything": func(_ context.Context, _ ActionRequest) (any, error) { + ran++ + return "should-not-run", nil + }, + }); err != nil { + t.Fatalf("RegisterActions: %v", err) + } + if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.nukeEverything"}); !errors.Is(err, ErrActionNotDeclared) { + t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err) + } + if ran != 0 { + t.Fatalf("未声明动作的处理器跑了 %d 次(声明闸门被绕过)", ran) + } + // 停用的插件同理:声明还在,但不再分发。 + if err := hub.RegisterActions("alpha", map[string]ActionHandler{ + "plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil }, + }); err != nil { + t.Fatalf("RegisterActions: %v", err) + } + hub.SetEnabled("alpha", false) + if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"}); !errors.Is(err, ErrPluginDisabled) { + t.Fatalf("停用后 err = %v,期望 ErrPluginDisabled", err) + } +} + +// TestPanels_RegisterActionsRejectsInvalidRegistration 锁定登记期的形状约束: +// 空命名空间 / 空名字 / nil 处理器一律当场拒(静默忽略会让「点了没反应」重新变成谜)。 +func TestPanels_RegisterActionsRejectsInvalidRegistration(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + ok := func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil } + + cases := []struct { + name string + pluginID string + handlers map[string]ActionHandler + }{ + {"空命名空间", "", map[string]ActionHandler{"plugin.listProviders": ok}}, + {"非法命名空间", "not a key", map[string]ActionHandler{"plugin.listProviders": ok}}, + {"空处理器表", "alpha", map[string]ActionHandler{}}, + {"空动作名", "alpha", map[string]ActionHandler{" ": ok}}, + {"nil 处理器", "alpha", map[string]ActionHandler{"plugin.listProviders": nil}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := hub.RegisterActions(tc.pluginID, tc.handlers) + if !errors.Is(err, ErrInvalidActionRegistration) { + t.Fatalf("err = %v,期望 ErrInvalidActionRegistration", err) + } + }) + } + // 被拒的登记不得留下任何痕迹(半登记最坏:看起来接好了,实际没人执行)。 + if names := hub.ActionHandlerNames("alpha"); len(names) != 0 { + t.Fatalf("被拒的登记留下了处理器: %v", names) + } +} + +// TestPanels_RegisterActionsMergesAndOverwrites 锁定重复登记的语义: +// 第二次登记是**前缀合并**,同名覆盖(插件可分批登记自己的动作面)。 +func TestPanels_RegisterActionsMergesAndOverwrites(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider") + + first := func(_ context.Context, _ ActionRequest) (any, error) { return "v1", nil } + second := func(_ context.Context, _ ActionRequest) (any, error) { return "v2", nil } + if err := hub.RegisterActions("alpha", map[string]ActionHandler{ + "plugin.listProviders": first, + "plugin.saveProvider": first, + }); err != nil { + t.Fatalf("RegisterActions: %v", err) + } + if err := hub.RegisterActions("alpha", map[string]ActionHandler{ + "plugin.listProviders": second, + }); err != nil { + t.Fatalf("RegisterActions(merge): %v", err) + } + if got := hub.ActionHandlerNames("alpha"); strings.Join(got, ",") != "plugin.listProviders,plugin.saveProvider" { + t.Fatalf("合并后的处理器表 = %v", got) + } + out, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"}) + if err != nil || out != "v2" { + t.Fatalf("同名覆盖后 Dispatch = (%v,%v),期望 (v2,nil)", out, err) + } + out, err = hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"}) + if err != nil || out != "v1" { + t.Fatalf("未覆盖的那条 Dispatch = (%v,%v),期望 (v1,nil)", out, err) + } +} + +// TestManager_RegisterActionsRequiresAttachedHub 锁定管理器侧入口的降级口径: +// 面板中枢未接线时**响亮失败**(组合根会以为动作接好了,而端点在 501)。 +func TestManager_RegisterActionsRequiresAttachedHub(t *testing.T) { + t.Parallel() + mgr := NewManager(nil, t.TempDir()+"/plugins.json", nil) + err := mgr.RegisterActions("alpha", map[string]ActionHandler{ + "plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil }, + }) + if !errors.Is(err, ErrInvalidActionRegistration) { + t.Fatalf("未接线中枢时 err = %v,期望 ErrInvalidActionRegistration", err) + } + hub, herr := NewPanels(nil) + if herr != nil { + t.Fatalf("NewPanels: %v", herr) + } + mgr.AttachPanels(hub) + if err := mgr.RegisterActions("alpha", map[string]ActionHandler{ + "plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil }, + }); err != nil { + t.Fatalf("接线后 RegisterActions: %v", err) + } + if names := hub.ActionHandlerNames("alpha"); strings.Join(names, ",") != "plugin.listProviders" { + t.Fatalf("管理器登记没有落到中枢上: %v", names) + } +} + +// TestActionErrorCarriesStructuredFailure 锁定结构化失败的形状与可判类 +// (§5.4.6:错误码与文案分离;病因只进日志、不进响应)。 +func TestActionErrorCarriesStructuredFailure(t *testing.T) { + t.Parallel() + cause := errors.New("disk full") + err := fmt.Errorf("save failed: %w", NewActionError("config_write_failed", "写配置失败", 500, cause)) + + ae, ok := AsActionError(err) + if !ok { + t.Fatalf("AsActionError 没认出结构化失败: %v", err) + } + if ae.Code != "config_write_failed" || ae.Message != "写配置失败" || ae.Status != 500 { + t.Fatalf("ActionError = %+v", ae) + } + if !errors.Is(err, cause) { + t.Fatalf("err 链必须穿到病因(errors.Is(cause) 为假): %v", err) + } + if _, ok := AsActionError(context.Canceled); ok { + t.Fatalf("普通错误不得被当成结构化失败") + } +} diff --git a/internal/capability/plugin/panelactions.go b/internal/capability/plugin/panelactions.go new file mode 100644 index 0000000..f35806a --- /dev/null +++ b/internal/capability/plugin/panelactions.go @@ -0,0 +1,190 @@ +package plugin + +// 本文件是面板声明的**动作索引与分发**(切片 L1/PLG-2 从 panels.go 拆出,为守 +//「文件 ≤500 行」): +// +// - 读写面分类(`ActionKind`)与分发请求(`ActionRequest`)的形状; +// - 索引的推导(`actionIndex`:submit / rowActions / source / optionsFrom 四个来源); +// - 候选来源映射(`OptionsActionName`,与前端 `resolveOptionsAction` 同源); +// - 分发(`Dispatch`:声明 → 启用 → 处理器三条闸门)与处理器查找。 +// +// 登记的入口在 actions.go;目录/生命周期/变更通知在 panels.go。 + +import ( + "context" + "fmt" + "sort" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// ActionKind 分类一个已声明动作的读写面(§5.4.1:只读事实面对 viewer 可见, +// 写动作一律 owner/admin)。分类由**声明位置**决定,不由名字猜: +// form 的 submit 与 table 的 rowActions 是写面,table/log 的 source 是读面。 +type ActionKind string + +const ( + // ActionRead 是只读来源动作(section.table / section.log 的 source,以及下拉的 optionsFrom)。 + ActionRead ActionKind = "read" + // ActionWrite 是写动作(section.form 的 submit、section.table 的 rowActions)。 + ActionWrite ActionKind = "write" +) + +// ActionRequest 是一次已声明动作的分发请求。 +type ActionRequest struct { + // PluginID 是声明该动作的插件 id(目录命名空间)。 + PluginID string + // Action 是插件声明的动作名(如 plugin.saveProvider)。它只是名字,不是 URL。 + Action string + // Payload 是请求体解码后的任意值(缺省为 nil)。宿主原样交给处理器, + // 自身不解释插件的数据形状。 + Payload any +} + +// ActionExecutor 与 SetExecutor 已被 RegisterActions(见 actions.go)取代(切片 L1/PLG-2 +// 的执行面接线):单一的全局执行器说不出「哪个插件负责哪个名字」,而登记表能把这件事 +// 变成可查的事实。未登记处理器的已声明动作仍返回 ErrActionUnavailable(501),语义不变。 + +// Action 报告 pluginID 是否声明过 action,并给出它的读写面。 +func (p *Panels) Action(pluginID, action string) (ActionKind, bool) { + if p == nil || action == "" { + return "", false + } + p.mu.RLock() + defer p.mu.RUnlock() + kind, ok := p.actionIndexLocked(pluginID)[action] + return kind, ok +} + +// DeclaredActions 返回某插件已声明的动作名(已排序,读/写混在一起)。 +// 它面向诊断与测试;鉴权判定请用 Action(读写面要分开)。 +func (p *Panels) DeclaredActions(pluginID string) []string { + if p == nil { + return nil + } + p.mu.RLock() + defer p.mu.RUnlock() + idx := p.actionIndexLocked(pluginID) + out := make([]string, 0, len(idx)) + for name := range idx { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// Dispatch 把一次动作交给**该插件登记过的**处理器。它按「未声明 ⇒ 未声明」 +// 「已停用 ⇒ 停用」「未登记处理器 ⇒ 未接线」三条分别报错,绝不用一个笼统失败掩盖病因。 +// +// 路由层已在进门前校验过动作名与角色;这里再校验一次是**纵深防御**: +// Dispatch 是唯一的执行入口,不能只靠调用方自觉。处理器按 (pluginId, action) 精确取, +// 一个插件登记的名字不会替另一个插件作答。 +func (p *Panels) Dispatch(ctx context.Context, req ActionRequest) (any, error) { + if p == nil { + return nil, fmt.Errorf("plugin: nil panel hub") + } + if _, ok := p.Action(req.PluginID, req.Action); !ok { + return nil, fmt.Errorf("%w: %s/%s", ErrActionNotDeclared, req.PluginID, req.Action) + } + if !p.Enabled(req.PluginID) { + return nil, fmt.Errorf("%w: %s", ErrPluginDisabled, req.PluginID) + } + if handler := p.actionHandler(req.PluginID, req.Action); handler != nil { + return handler(ctx, req) + } + return nil, fmt.Errorf("%w: %s/%s", ErrActionUnavailable, req.PluginID, req.Action) +} + +// actionHandler 取该插件的动作处理器(没有则 nil)。锁在返回前释放: +// 处理器是外部代码,持锁调用等于把中枢的锁交给调用方(本项目踩过同类自锁死)。 +func (p *Panels) actionHandler(pluginID, action string) ActionHandler { + p.mu.RLock() + defer p.mu.RUnlock() + return p.handlers[pluginID][action] +} + +// actionIndexLocked 返回某插件的「动作名 → 读写面」索引。调用方必须持有 p.mu。 +func (p *Panels) actionIndexLocked(pluginID string) map[string]ActionKind { + return actionIndex(p.decls[pluginID]) +} + +// actionIndex 从一个插件的声明集里抽出动作索引。 +// +// 同一动作名可能同时出现在读面与写面(例如 table 的 source 与某个 form 的 submit +// 同名);此时一律按**写**处理 —— 收紧方向永远是对的,放宽才需要证据(§5.4.1)。 +// +// 下拉字段的 `optionsFrom` 也进索引(读面):它同样是**声明里写下的动作名** —— +// 前端(`resolveOptionsAction`)按 `plugin:` → `plugin.` 调宿主动作端点 +// 拉候选,宿主若不认这个名字,控件就只能降级成文本框。索引由声明推出(不是另一份 +// 白名单),因此「声明里写了却没人实现」仍由登记表如实表现为 501。 +func actionIndex(panels []sdk.Panel) map[string]ActionKind { + out := make(map[string]ActionKind) + for _, panel := range panels { + for _, s := range panel.Sections { + if s.Submit != nil { + out[s.Submit.Action] = ActionWrite + } + for _, ra := range s.RowActions { + out[ra.Action] = ActionWrite + } + if s.Source != nil { + if _, exists := out[s.Source.Action]; !exists { + out[s.Source.Action] = ActionRead + } + } + for _, f := range s.Fields { + name := OptionsActionName(f.OptionsFrom) + if name == "" { + continue + } + if _, exists := out[name]; !exists { + out[name] = ActionRead + } + } + } + } + return out +} + +// OptionsActionName 把字段声明的候选来源(`plugin:<名字>`)映射成宿主动作名。 +// +// 口径与前端 `admin/plugin/actions.ts:resolveOptionsAction` **逐字一致**: +// 去掉 `plugin:` 前缀再逐字加前缀 ⇒ `plugin:providers` → `plugin.providers`、 +// `plugin:search.regions` → `plugin.search.regions`。两处必须同源,否则声明的下拉 +// 会去调一个宿主不认识的名字(表现就是「候选项加载失败,请手填」)。 +// +// 不是 `plugin:` 前缀(或前缀后为空)时返回空串:`optionsFrom` 的字符集已由 +// paneldecl 校验(`plugin:` + 名字),这里只做形状兜底、不重复一项策略。 +func OptionsActionName(optionsFrom string) string { + raw := strings.TrimSpace(optionsFrom) + if !strings.HasPrefix(raw, "plugin:") { + return "" + } + name := strings.TrimSpace(strings.TrimPrefix(raw, "plugin:")) + if name == "" { + return "" + } + return "plugin." + name +} + +// keepAccepted 只留下被目录接受的声明(按 id 比对)。 +// +// 用途是让**动作索引**与目录一致:被拒面板的动作不得可调用。normalizePanel 只改 +// Group、不改 ID,因此按 ID 过滤是与目录等价的判据。 +func keepAccepted(panels []sdk.Panel, accepted []string) []sdk.Panel { + if len(accepted) == 0 { + return nil + } + ok := make(map[string]bool, len(accepted)) + for _, id := range accepted { + ok[id] = true + } + out := make([]sdk.Panel, 0, len(accepted)) + for _, p := range panels { + if ok[p.ID] { + out = append(out, p) + } + } + return out +} diff --git a/internal/capability/plugin/panels.go b/internal/capability/plugin/panels.go index 91230ad..36d0723 100644 --- a/internal/capability/plugin/panels.go +++ b/internal/capability/plugin/panels.go @@ -1,8 +1,8 @@ package plugin // 本文件把 design/09-插件生态 §5.2.1「插件管理器 = 唯一中枢」接到 PLG-1 的 -// paneldecl 库上:声明登记 → 目录(catalog)→ 生命周期重算 → 变更通知, -// 外加「已声明动作」的读写面索引,供网关的动作分发端点使用。 +// paneldecl 库上:声明登记 → 目录(catalog)→ 生命周期重算 → 变更通知。 +// 「已声明动作」的读写面索引与分发在 panelactions.go,执行面登记在 actions.go。 // // 为什么中枢放在插件管理器而不是网关:§5.2.1 明写「插件既不直接操作前端注册表, // 也不直接读写自己的配置文件,两条路都必须经过插件管理器」。安装/卸载/启停的真源 @@ -16,11 +16,9 @@ package plugin // 进程内 bus 的 `ws.panels_changed` 上)。 import ( - "context" "errors" "fmt" "log/slog" - "sort" "strings" "sync" @@ -57,34 +55,6 @@ var ( ErrPluginDisabled = errors.New("plugin: plugin is disabled") ) -// ActionKind 分类一个已声明动作的读写面(§5.4.1:只读事实面对 viewer 可见, -// 写动作一律 owner/admin)。分类由**声明位置**决定,不由名字猜: -// form 的 submit 与 table 的 rowActions 是写面,table/log 的 source 是读面。 -type ActionKind string - -const ( - // ActionRead 是只读来源动作(section.table / section.log 的 source)。 - ActionRead ActionKind = "read" - // ActionWrite 是写动作(section.form 的 submit、section.table 的 rowActions)。 - ActionWrite ActionKind = "write" -) - -// ActionRequest 是一次已声明动作的分发请求。 -type ActionRequest struct { - // PluginID 是声明该动作的插件 id(目录命名空间)。 - PluginID string - // Action 是插件声明的动作名(如 plugin.saveProvider)。它只是名字,不是 URL。 - Action string - // Payload 是请求体解码后的任意值(缺省为 nil)。宿主原样交给执行器, - // 自身不解释插件的数据形状。 - Payload any -} - -// ActionExecutor 执行插件动作。宿主端点只把**已声明**的动作交给它,因此实现 -// 无需再做「这个名字合法吗」的判断;但**绝不允许**由它去发起插件提供的 URL —— -// §5.4.4 的动作面只有名字。 -type ActionExecutor func(ctx context.Context, req ActionRequest) (any, error) - // PanelChange 是一次面板集合变更。它是发布给前端的热更新信号,**不含任何面板 // 内容**:前端按自己的角色重新拉一次 `GET /api/v1/admin/panels` 即可,避免 // 在帧里再实现一次角色过滤。 @@ -96,7 +66,7 @@ type PanelChange struct { PluginID string } -// Panels 是并发安全的面板声明中枢:目录 + 启用状态 + 动作索引 + 变更发布。 +// Panels 是并发安全的面板声明中枢:目录 + 启用状态 + 动作索引 + 处理器表 + 变更发布。 // // 它必须经 NewPanels 构造(目录需要非 nil 的 when 求值上下文来源)。 type Panels struct { @@ -104,9 +74,11 @@ type Panels struct { cat *paneldecl.Catalog decls map[string][]sdk.Panel enabled map[string]bool - exec ActionExecutor - publish func(PanelChange) - log *slog.Logger + // handlers 是「插件 → 动作名 → 处理器」的登记表(见 RegisterActions)。 + // 它与 decls(声明)是两个不同的事实:声明决定**可不可以调**,登记决定**谁来执行**。 + handlers map[string]map[string]ActionHandler + publish func(PanelChange) + log *slog.Logger } // NewPanels 构造面板中枢。log 为 nil 时使用丢弃型 logger。 @@ -115,9 +87,10 @@ func NewPanels(log *slog.Logger) (*Panels, error) { log = slog.New(slog.DiscardHandler) } p := &Panels{ - decls: make(map[string][]sdk.Panel), - enabled: make(map[string]bool), - log: log, + decls: make(map[string][]sdk.Panel), + enabled: make(map[string]bool), + handlers: make(map[string]map[string]ActionHandler), + log: log, } cat, err := paneldecl.NewCatalog(p.resolve) if err != nil { @@ -138,17 +111,6 @@ func (p *Panels) SetPublisher(pub func(PanelChange)) { p.mu.Unlock() } -// SetExecutor 注入动作执行器。未注入时已声明动作返回 ErrActionUnavailable -// (501),而不是静默成功。 -func (p *Panels) SetExecutor(exec ActionExecutor) { - if p == nil { - return - } - p.mu.Lock() - p.exec = exec - p.mu.Unlock() -} - // Declare 用 panels 整体替换 pluginID 的声明集(§5.2.1 的「整体替换」口径), // 返回真正生效的面板 id。非法插件 id 整插件拒绝(目录键不了它);某个面板非法 // 只拒该面板(口径与理由见 paneldecl.Catalog.Register)。 @@ -242,58 +204,6 @@ func (p *Panels) PluginIDs() []string { return p.cat.PluginIDs() } -// Action 报告 pluginID 是否声明过 action,并给出它的读写面。 -func (p *Panels) Action(pluginID, action string) (ActionKind, bool) { - if p == nil || action == "" { - return "", false - } - p.mu.RLock() - defer p.mu.RUnlock() - kind, ok := p.actionIndexLocked(pluginID)[action] - return kind, ok -} - -// DeclaredActions 返回某插件已声明的动作名(已排序,读/写混在一起)。 -// 它面向诊断与测试;鉴权判定请用 Action(读写面要分开)。 -func (p *Panels) DeclaredActions(pluginID string) []string { - if p == nil { - return nil - } - p.mu.RLock() - defer p.mu.RUnlock() - idx := p.actionIndexLocked(pluginID) - out := make([]string, 0, len(idx)) - for name := range idx { - out = append(out, name) - } - sort.Strings(out) - return out -} - -// Dispatch 把一次动作交给执行器。它按「未声明 ⇒ 未声明」「已停用 ⇒ 停用」 -// 「未接线 ⇒ 未接线」三条分别报错,绝不用一个笼统失败掩盖病因。 -// -// 路由层已在进门前校验过动作名与角色;这里再校验一次是**纵深防御**: -// Dispatch 是唯一的执行入口,不能只靠调用方自觉。 -func (p *Panels) Dispatch(ctx context.Context, req ActionRequest) (any, error) { - if p == nil { - return nil, fmt.Errorf("plugin: nil panel hub") - } - if _, ok := p.Action(req.PluginID, req.Action); !ok { - return nil, fmt.Errorf("%w: %s/%s", ErrActionNotDeclared, req.PluginID, req.Action) - } - if !p.Enabled(req.PluginID) { - return nil, fmt.Errorf("%w: %s", ErrPluginDisabled, req.PluginID) - } - p.mu.RLock() - exec := p.exec - p.mu.RUnlock() - if exec == nil { - return nil, fmt.Errorf("%w: %s/%s", ErrActionUnavailable, req.PluginID, req.Action) - } - return exec(ctx, req) -} - // setEnabled 是启用态变更的唯一实现(force=true 表示无论是否变化都发通知, // 供 install/restore 这类「生命周期事件本身就值得一帧」的场合使用)。 func (p *Panels) setEnabled(pluginID string, on bool, reason string, force bool) { @@ -346,56 +256,6 @@ func (p *Panels) enabledLocked(pluginID string) bool { return on } -// actionIndexLocked 返回某插件的「动作名 → 读写面」索引。调用方必须持有 p.mu。 -func (p *Panels) actionIndexLocked(pluginID string) map[string]ActionKind { - return actionIndex(p.decls[pluginID]) -} - -// actionIndex 从一个插件的声明集里抽出动作索引。 -// -// 同一动作名可能同时出现在读面与写面(例如 table 的 source 与某个 form 的 submit -// 同名);此时一律按**写**处理 —— 收紧方向永远是对的,放宽才需要证据(§5.4.1)。 -func actionIndex(panels []sdk.Panel) map[string]ActionKind { - out := make(map[string]ActionKind) - for _, panel := range panels { - for _, s := range panel.Sections { - if s.Submit != nil { - out[s.Submit.Action] = ActionWrite - } - for _, ra := range s.RowActions { - out[ra.Action] = ActionWrite - } - if s.Source != nil { - if _, exists := out[s.Source.Action]; !exists { - out[s.Source.Action] = ActionRead - } - } - } - } - return out -} - -// keepAccepted 只留下被目录接受的声明(按 id 比对)。 -// -// 用途是让**动作索引**与目录一致:被拒面板的动作不得可调用。normalizePanel 只改 -// Group、不改 ID,因此按 ID 过滤是与目录等价的判据。 -func keepAccepted(panels []sdk.Panel, accepted []string) []sdk.Panel { - if len(accepted) == 0 { - return nil - } - ok := make(map[string]bool, len(accepted)) - for _, id := range accepted { - ok[id] = true - } - out := make([]sdk.Panel, 0, len(accepted)) - for _, p := range panels { - if ok[p.ID] { - out = append(out, p) - } - } - return out -} - // PanelPluginID 给出一个已安装插件在面板目录里的键(命名空间)。 // // 口径:**优先 Name**(它是 plugins.json 的唯一标签、跨重启稳定、也是用户/面板 diff --git a/internal/capability/plugin/panels_test.go b/internal/capability/plugin/panels_test.go index 3d71443..c7fbe7f 100644 --- a/internal/capability/plugin/panels_test.go +++ b/internal/capability/plugin/panels_test.go @@ -202,7 +202,8 @@ func TestPanels_InvalidPluginIDIsRejectedWholesale(t *testing.T) { } // TestPanels_ActionKindsFollowDeclarationSite 锁定读写面由**声明位置**决定: -// table/log 的 source 是读来源,form 的 submit 与 table 的 rowActions 是写面。 +// table/log 的 source 与下拉的 optionsFrom 是读来源,form 的 submit 与 table 的 +// rowActions 是写面。 func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) { t.Parallel() hub, _ := newTestHub(t) @@ -216,6 +217,9 @@ func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) { {"plugin.listProviders", ActionRead}, {"plugin.saveProvider", ActionWrite}, {"plugin.testProvider", ActionWrite}, + // 下拉候选来源 `plugin:providers` 也进索引(读面):前端按它拉候选, + // 宿主不认这个名字,控件就只能降级成文本框。 + {"plugin.providers", ActionRead}, } for _, c := range cases { got, ok := hub.Action("web-search", c.action) @@ -227,11 +231,31 @@ func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) { t.Fatalf("未声明的动作不得被判为已声明") } if got := hub.DeclaredActions("web-search"); strings.Join(got, ",") != - "plugin.listProviders,plugin.saveProvider,plugin.testProvider" { + "plugin.listProviders,plugin.providers,plugin.saveProvider,plugin.testProvider" { t.Fatalf("DeclaredActions = %v", got) } } +// TestOptionsActionNameMatchesFrontendMapping 锁定 `optionsFrom` → 动作名的映射 +// 与前端 `resolveOptionsAction` **逐字同源**(前端片 PLG-3 登记的口径): +// 两处一旦分叉,声明的下拉就会去调一个宿主不认识的名字。 +func TestOptionsActionNameMatchesFrontendMapping(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "plugin:providers": "plugin.providers", + "plugin:search.regions": "plugin.search.regions", + " plugin:providers ": "plugin.providers", + "plugin:": "", + "providers": "", + "": "", + } + for in, want := range cases { + if got := OptionsActionName(in); got != want { + t.Errorf("OptionsActionName(%q) = %q, want %q", in, got, want) + } + } +} + // TestPanels_DispatchThreeWayRefusal 锁定分发失败的三种病因各自可判类, // 且执行器只在「已声明 + 已启用 + 已接线」时才被调用。 func TestPanels_DispatchThreeWayRefusal(t *testing.T) { @@ -246,23 +270,27 @@ func TestPanels_DispatchThreeWayRefusal(t *testing.T) { t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err) } if _, err := hub.Dispatch(context.Background(), req); !errors.Is(err, ErrActionUnavailable) { - t.Fatalf("未接线执行器 err = %v,期望 ErrActionUnavailable", err) + t.Fatalf("未登记处理器 err = %v,期望 ErrActionUnavailable", err) } var seen ActionRequest - hub.SetExecutor(func(_ context.Context, r ActionRequest) (any, error) { - seen = r - return map[string]any{"rows": 1}, nil - }) + if err := hub.RegisterActions("web-search", map[string]ActionHandler{ + "plugin.listProviders": func(_ context.Context, r ActionRequest) (any, error) { + seen = r + return map[string]any{"rows": 1}, nil + }, + }); err != nil { + t.Fatalf("RegisterActions: %v", err) + } out, err := hub.Dispatch(context.Background(), req) if err != nil { t.Fatalf("Dispatch: %v", err) } if seen.PluginID != "web-search" || seen.Action != "plugin.listProviders" { - t.Fatalf("执行器收到 %+v", seen) + t.Fatalf("处理器收到 %+v", seen) } if m, ok := out.(map[string]any); !ok || m["rows"] != 1 { - t.Fatalf("Dispatch 返回 %#v,期望执行器结果原样透传", out) + t.Fatalf("Dispatch 返回 %#v,期望处理器结果原样透传", out) } hub.SetEnabled("web-search", false) diff --git a/internal/services/gateway/admin_panels.go b/internal/services/gateway/admin_panels.go index a846eee..d27645b 100644 --- a/internal/services/gateway/admin_panels.go +++ b/internal/services/gateway/admin_panels.go @@ -147,7 +147,7 @@ func adminPanelGroupOf(g paneldecl.Group) AdminPanelGroup { // 1. 未声明 ⇒ 404(插件只给动作名 ⇒ 未声明的名字根本不存在,不是「没权限」); // 2. 写动作 ⇒ 必须 owner/admin(读来源动作对 viewer 开放,§5.4.1); // 3. 停止用插件 ⇒ 409; -// 4. 未接线执行器 ⇒ 501(诚实作答,绝不假装执行成功)。 +// 4. 已声明但没登记处理器 ⇒ 501(诚实作答,绝不假装执行成功)。 // // 每一步都落审计:谁在什么时候想动哪个插件的哪个动作、结果如何。 func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) { @@ -155,13 +155,13 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) { action := strings.TrimSpace(r.PathValue("action")) hub := s.panelHub() if hub == nil { - s.auditPanelAction(r, pluginID, action, "unavailable") + s.auditPanelAction(r, pluginID, action, "unavailable", "panels_unavailable") writeError(w, panelsUnavailable()) return } kind, declared := hub.Action(pluginID, action) if !declared { - s.auditPanelAction(r, pluginID, action, "not_declared") + s.auditPanelAction(r, pluginID, action, "not_declared", "action_not_declared") writeError(w, &apperr.AppError{ Code: "action_not_declared", Message: fmt.Sprintf("插件 %q 没有声明动作 %q(插件只给动作名,不给 URL)", pluginID, action), @@ -170,13 +170,13 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) { return } if kind == pluginmgr.ActionWrite && !callerHasWrite(r) { - s.auditPanelAction(r, pluginID, action, "denied") + s.auditPanelAction(r, pluginID, action, "denied", "forbidden") writeError(w, apperr.Wrap(apperr.ErrForbidden, "forbidden", "写动作需要 owner/admin(§5.4.1)")) return } payload, derr := decodeActionPayload(r) if derr != nil { - s.auditPanelAction(r, pluginID, action, "bad_request") + s.auditPanelAction(r, pluginID, action, "bad_request", "bad_request") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", derr)) return } @@ -187,11 +187,11 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) { }) if err != nil { status, code, msg := actionFailure(err) - s.auditPanelAction(r, pluginID, action, auditResultOf(err)) + s.auditPanelAction(r, pluginID, action, auditResultOf(err), code) writeError(w, &apperr.AppError{Code: code, Message: msg, Cause: err, HTTPCode: status}) return } - s.auditPanelAction(r, pluginID, action, "ok") + s.auditPanelAction(r, pluginID, action, "ok", "") writeJSON(w, http.StatusOK, map[string]any{ "pluginId": pluginID, "action": action, @@ -202,6 +202,10 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) { // actionFailure 把分发失败映射为 HTTP 状态 + 结构化错误码 + 文案 // (「错误码与文案分离」,§5.4.6:前端据此本地化,不靠解析字符串)。 +// +// 顺序是先认宿主哨兵(未声明 / 停用 / 未接线),再认**插件侧自己给出的** +// ActionError —— 插件比宿主更清楚这次失败是「请求写错了」还是「上游不可用」, +// 宿主只做透传,不替它猜病因;两者都不是的内部错误才落 action_failed。 func actionFailure(err error) (status int, code, message string) { switch { case errors.Is(err, pluginmgr.ErrActionNotDeclared): @@ -209,13 +213,43 @@ func actionFailure(err error) (status int, code, message string) { case errors.Is(err, pluginmgr.ErrPluginDisabled): return http.StatusConflict, "plugin_disabled", "插件已停用,其动作不再分发" case errors.Is(err, pluginmgr.ErrActionUnavailable): - return http.StatusNotImplemented, "action_not_implemented", "动作已声明,但宿主尚未接线执行器" - default: - return http.StatusBadGateway, "action_failed", "插件动作执行失败,详细原因见后端日志" + return http.StatusNotImplemented, "action_not_implemented", "动作已声明,但宿主尚未登记它的处理器" } + if ae, ok := pluginmgr.AsActionError(err); ok { + return actionErrorStatus(ae.Status), actionErrorCode(ae.Code), actionErrorText(ae.Message) + } + return http.StatusBadGateway, "action_failed", "插件动作执行失败,详细原因见后端日志" } -// auditResultOf 把分发失败归成审计链里的一档结果。 +// actionErrorStatus 归一结构化失败的状态码:0 或越界值一律回落到 502 +// (「上游/插件侧执行失败」是这条路径最诚实的默认档,不是 200)。 +func actionErrorStatus(status int) int { + if status < http.StatusBadRequest || status > 599 { + return http.StatusBadGateway + } + return status +} + +// actionErrorCode 兜住空失败码:没有码的失败退回 action_failed, +// 前端至少能显示一个稳定标识(空码会让提示行上少一段信息)。 +func actionErrorCode(code string) string { + if strings.TrimSpace(code) == "" { + return "action_failed" + } + return code +} + +// actionErrorText 兜住空文案:实现没给出人话时,回一句明确指向服务端日志的话, +// 绝不把空串当文案发给前端(那会渲染成一行无来由的红字)。 +func actionErrorText(message string) string { + if strings.TrimSpace(message) == "" { + return "插件动作执行失败,详细原因见后端日志" + } + return message +} + +// auditResultOf 把分发失败归成审计链里的一档结果(**分类**,不是插件域的错误码; +// 后者作为 code 附加字段进日志,见 auditPanelAction)。 func auditResultOf(err error) string { switch { case errors.Is(err, pluginmgr.ErrActionNotDeclared): @@ -230,13 +264,19 @@ func auditResultOf(err error) string { } // auditPanelAction 把一次插件动作的结局写进审计链(who / what / when / result)。 -// 附加字段只有插件 id 与动作名 —— 请求体可能有凭据,绝不进审计。 -func (s *Server) auditPanelAction(r *http.Request, pluginID, action, result string) { +// 附加字段只有插件 id、动作名与结构化失败码 —— 请求体可能有凭据,绝不进审计; +// 失败码是机器可读的稳定标识(不是请求内容),它让「审计里那次 error 到底是什么」 +// 不必翻服务端日志才能回答。 +func (s *Server) auditPanelAction(r *http.Request, pluginID, action, result, code string) { who := "anonymous" if claims, ok := auth.ClaimsFromContext(r.Context()); ok { who = claims.Sub + ":" + string(claims.Role) } - s.audit(r.Context(), who, "plugin.action", result, "pluginId", pluginID, "action", action) + extra := []any{"pluginId", pluginID, "action", action} + if strings.TrimSpace(code) != "" { + extra = append(extra, "code", code) + } + s.audit(r.Context(), who, "plugin.action", result, extra...) } // decodeActionPayload 解码可选的动作请求体:空体 ⇒ nil(多数读来源动作不需要参数), diff --git a/internal/services/gateway/admin_panels_test.go b/internal/services/gateway/admin_panels_test.go index 94ee36b..6e55017 100644 --- a/internal/services/gateway/admin_panels_test.go +++ b/internal/services/gateway/admin_panels_test.go @@ -3,6 +3,7 @@ package gateway import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -219,11 +220,11 @@ func TestAdminPluginActionUndeclaredIs404(t *testing.T) { } // TestAdminPluginActionWriteRequiresOwnerOrAdmin 锁定写动作门(§5.4.1): -// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已接线执行器 ⇒ 200 且结果透传。 +// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已登记处理器 ⇒ 200 且结果透传。 func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) { t.Parallel() env := newPanelTestEnv(t) - env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) { + registerTestActions(t, env.hub, "plugin.saveProvider", func(_ context.Context, req pluginmgr.ActionRequest) (any, error) { return map[string]any{"saved": req.Action, "payload": req.Payload}, nil }) url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider" @@ -259,7 +260,7 @@ func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) { func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) { t.Parallel() env := newPanelTestEnv(t) - env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) { + registerTestActions(t, env.hub, "plugin.listProviders", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { return []map[string]any{{"provider": "searxng"}}, nil }) resp, raw := doReq(t, http.MethodPost, @@ -272,9 +273,10 @@ func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) { } } -// TestAdminPluginActionHonest501WithoutExecutor 锁定「声明了但没接线」的诚实作答: -// 501 action_not_implemented,绝不回一份假的成功。 -func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) { +// TestAdminPluginActionHonest501WithoutHandler 锁定「声明了但宿主没登记处理器」的诚实 +// 作答:501 action_not_implemented,绝不回一份假的成功。它**不是**兜底档 —— +// 已登记处理器后同一条路必须真的执行(见下一个用例)。 +func TestAdminPluginActionHonest501WithoutHandler(t *testing.T) { t.Parallel() env := newPanelTestEnv(t) resp, raw := doReq(t, http.MethodPost, @@ -283,7 +285,45 @@ func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) { t.Fatalf("status = %d, body = %s,期望 501 action_not_implemented", resp.StatusCode, raw) } if !hasAudit(env.auditRecords(t), "plugin.action", "unimplemented") { - t.Fatalf("未接线的动作结算必须落审计(unimplemented)") + t.Fatalf("未登记处理器的动作结算必须落审计(unimplemented)") + } +} + +// TestAdminPluginActionMapsStructuredFailure 锁定插件侧结构化失败的透传口径 +// (§5.4.6):错误码与文案分离、状态码由实现给出、病因只进日志;且失败也落审计。 +func TestAdminPluginActionMapsStructuredFailure(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + registerTestActions(t, env.hub, "plugin.testProvider", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { + return nil, pluginmgr.NewActionError("unauthorized", "上游拒绝了凭据(401/403):请核对 API Key 是否有效", + http.StatusBadGateway, errors.New("upstream 401")) + }) + resp, raw := doReq(t, http.MethodPost, + env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.testProvider", + map[string]any{"row": map[string]any{"provider": "zhipu"}}, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, body = %s,期望 502(上游失败)", resp.StatusCode, raw) + } + var out struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode: %v (%s)", err, raw) + } + if out.Error.Code != "unauthorized" { + t.Fatalf("错误码 = %q,期望实现给出的 unauthorized(不是笼统的 action_failed)", out.Error.Code) + } + if !strings.Contains(out.Error.Message, "上游拒绝了凭据") { + t.Fatalf("文案 = %q,期望实现给出的那句话(错误码与文案分离)", out.Error.Message) + } + if strings.Contains(string(raw), "upstream 401") { + t.Fatalf("病因(底层报错原文)不得进响应体:%s", raw) + } + if !hasAudit(env.auditRecords(t), "plugin.action", "error") { + t.Fatalf("失败的插件动作必须落审计(error)") } } @@ -304,7 +344,7 @@ func TestAdminPluginActionDisabledIs409(t *testing.T) { func TestAdminPluginActionRejectsMalformedBody(t *testing.T) { t.Parallel() env := newPanelTestEnv(t) - env.hub.SetExecutor(func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { + registerTestActions(t, env.hub, "plugin.saveProvider", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { return "should-not-run", nil }) url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider" @@ -343,6 +383,15 @@ func readBody(t *testing.T, resp *http.Response) []byte { return b } +// registerTestActions 给测试插件(web-search)登记一个动作处理器 —— 走的是生产同一条 +// 登记面(`Panels.RegisterActions`),不另开测试专用的后门。 +func registerTestActions(t *testing.T, hub *pluginmgr.Panels, action string, handler pluginmgr.ActionHandler) { + t.Helper() + if err := hub.RegisterActions("web-search", map[string]pluginmgr.ActionHandler{action: handler}); err != nil { + t.Fatalf("RegisterActions(%s): %v", action, err) + } +} + // hasAudit 报告审计链里是否存在指定的 (what, result) 记录。 func hasAudit(recs []AuditRecord, what, result string) bool { for _, r := range recs {