feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)

- 插件管理器加生产动作处理器登记面(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 补)。
This commit is contained in:
AskaEth committed 2026-10-06 14:12:23 +08:00
1 parent a150211468
commit 2e1c3ed3fd
13 files changed
+2043 -204

No files matched your search

+413
View File
@@ -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)
}
@@ -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)
}
}
+117
View File
@@ -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"
}
+29 -21
View File
@@ -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 把**编译进二进制的内置插件**的面板声明交给中枢
+188
View File
@@ -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,
}
}
+448
View File
@@ -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)
}
}
+190
View File
@@ -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
}
+220
View File
@@ -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("普通错误不得被当成结构化失败")
}
}
+190
View File
@@ -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:<name>` → `plugin.<name>` 调宿主动作端点
// 拉候选,宿主若不认这个名字,控件就只能降级成文本框。索引由声明推出(不是另一份
// 白名单),因此「声明里写了却没人实现」仍由登记表如实表现为 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
}
+12 -152
View File
@@ -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 的唯一标签、跨重启稳定、也是用户/面板
+37 -9
View File
@@ -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)
+54 -14
View File
@@ -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(多数读来源动作不需要参数),
+57 -8
View File
@@ -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 {