diff --git a/e2e/conflict_arbitration_e2e_test.go b/e2e/conflict_arbitration_e2e_test.go new file mode 100644 index 0000000..03d0310 --- /dev/null +++ b/e2e/conflict_arbitration_e2e_test.go @@ -0,0 +1,261 @@ +package e2e + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才 +// 落地内置插件,所以先放自定义插件再启动网关)。 +func writeE2EPlugin(t *testing.T, dir, name, code string) { + t.Helper() + p := filepath.Join(dir, "plugins", name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(code), 0o644); err != nil { + t.Fatal(err) + } +} + +// e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。 +func e2eConfig(dir, listen string) string { + return "listen: " + listen + "\n" + + "gateway_keys:\n - sk-e2e-0001\n" + + "default_model: AUTO\n" + + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + + "sources: []\n" +} + +// TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决) +// +// 真二进制全链路: +// 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常 +// 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边) +// 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决) +// 4. POST /api/plugins/conflicts 裁决 → 持久化 +// 5. 再请求该路径 → 200,赢家应答({"from":"B"}) +// 6. /v1/models 等既有路由全程不受影响(旧通路无回归) +func TestConflictArbitrationE2E(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + addr := mustAddr(t) + + writeE2EPlugin(t, dir, "plugA.lua", ` +local P = {} +P.name = "plugA" +P.version = "1" +P.exports = {} +P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } +function P.h(req) return { status = 200, json = { from = "A" } } end +return P +`) + writeE2EPlugin(t, dir, "plugB.lua", ` +local P = {} +P.name = "plugB" +P.version = "1" +P.exports = {} +P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } +function P.h(req) return { status = 200, json = { from = "B" } } end +return P +`) + + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { + t.Fatal(err) + } + g := startGateway(t, bin, addr, cfgPath) + + // ① 两个插件都加载成功(冲突不拒载) + resp, body := g.do(http.MethodGet, "/api/plugins", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body) + } + var list struct { + Plugins []struct { + Name string `json:"name"` + Loaded bool `json:"loaded"` + } `json:"plugins"` + } + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatal(err) + } + loaded := map[string]bool{} + for _, p := range list.Plugins { + loaded[p.Name] = p.Loaded + } + if !loaded["plugA"] || !loaded["plugB"] { + t.Fatalf("冲突的两个插件都应 loaded: %v", loaded) + } + + // ② 请求冲突路径 → 409 + 点名候选(不静默选边) + resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body) + } + if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") { + t.Fatalf("409 应点名两个候选: %s", body) + } + + // ③ 冲突列表可见 + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) + } + var cl struct { + Routes []struct { + Path string `json:"path"` + Candidates []string `json:"candidates"` + Resolved string `json:"resolved"` + } `json:"routes"` + } + if err := json.Unmarshal([]byte(body), &cl); err != nil { + t.Fatal(err) + } + if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" { + t.Fatalf("冲突形态不对: %+v", cl) + } + + // ④ 裁决 plugB 赢 + arbit, _ := json.Marshal(map[string]string{ + "kind": "route", "path": "contested", "winner": "plugB", + }) + resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true) + if resp.StatusCode != 200 { + t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body) + } + + // ⑤ 再请求 → 200 且赢家应答 + resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) + if resp.StatusCode != 200 { + t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body) + } + if !strings.Contains(body, `"from":"B"`) { + t.Fatalf("赢家 plugB 应答, 实得 %s", body) + } + + // ⑥ 旧通路无回归:/v1/models 正常 + resp, _ = g.do(http.MethodGet, "/v1/models", "", true) + if resp.StatusCode != 200 { + t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode) + } +} + +// TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留), +// /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。 +func TestAdapterConflictE2E(t *testing.T) { + bin := buildBinary(t) + dir := t.TempDir() + addr := mustAddr(t) + + writeE2EPlugin(t, dir, "shadow.lua", ` +local P = {} +P.name = "shadow" +P.version = "1" +P.exports = { "install" } +function P.install(args) + local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a' + local name, err = __llmsproxy_adapters.install("openai", src) + return { name = name, err = err } +end +return P +`) + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { + t.Fatal(err) + } + g := startGateway(t, bin, addr, cfgPath) + + // ① 安装 → 返回 err(pending 语义),内置版本不变 + resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true) + if resp.StatusCode != 200 { + t.Fatalf("call install: %d %s", resp.StatusCode, body) + } + // Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。 + var wrapped struct { + Result struct { + Name *string `json:"name"` + Err string `json:"err"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(body), &wrapped); err != nil { + t.Fatal(err) + } + out := wrapped.Result + if out.Name != nil { + t.Fatalf("挂起时不得返回生效名: %s", body) + } + if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") { + t.Fatalf("错误应点名冲突: %q", out.Err) + } + + // ② 冲突列表可见 + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) + } + var cl struct { + Adapters []struct { + Name string `json:"name"` + Candidate string `json:"candidate"` + } `json:"adapters"` + } + if err := json.Unmarshal([]byte(body), &cl); err != nil { + t.Fatal(err) + } + if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" { + t.Fatalf("冲突形态不对: %+v", cl) + } + + // ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖) + assertOpenAIVersion := func(want string, msg string) { + t.Helper() + resp, body := g.do(http.MethodGet, "/api/adapters", "", true) + if resp.StatusCode != 200 { + t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body) + } + var al struct { + Adapters []struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"adapters"` + } + if err := json.Unmarshal([]byte(body), &al); err != nil { + t.Fatal(err) + } + for _, a := range al.Adapters { + if a.Name == "openai" { + if a.Version != want { + t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version) + } + return + } + } + t.Fatalf("%s: openai 适配器不存在: %s", msg, body) + } + assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)") + + // ④ 裁决 candidate → 插件版生效 + arbit, _ := json.Marshal(map[string]string{ + "kind": "adapter", "name": "openai", "winner": "candidate", + }) + if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 { + t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body) + } + + // ⑤ 适配器版本已切换 + 冲突清空 + assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效") + resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) + if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) { + t.Fatalf("裁决后冲突应清除: %s", body) + } +} diff --git a/internal/gateway/plugin_http_routes.go b/internal/gateway/plugin_http_routes.go index d7f8ce1..4b43848 100644 --- a/internal/gateway/plugin_http_routes.go +++ b/internal/gateway/plugin_http_routes.go @@ -17,6 +17,7 @@ package gateway import ( "net/http" + "sort" "strconv" "strings" @@ -45,12 +46,25 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { matches := ps.HTTPHandlers() var chosen *lua.HTTPHandler var params map[string]string + // contenders holds the handlers that matched at the current best depth with + // the SAME literal route pattern — i.e. two plugins that both declared this + // exact path. That is the contested case the operator must arbitrate (user + // requirement A): rather than let load order pick a silent winner, a + // contested path with no recorded decision returns 409 naming the candidates. + var contenders []lua.HTTPHandler for i := range matches { h := matches[i] - if p, ok := matchHandlerPath(h.Path, rest); ok { - if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) { - chosen, params = &h, p - } + p, ok := matchHandlerPath(h.Path, rest) + if !ok { + continue + } + switch { + case chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path): + chosen, params = &matches[i], p + contenders = []lua.HTTPHandler{matches[i]} + case segmentCount(h.Path) == segmentCount(chosen.Path) && h.Path == chosen.Path: + // Same route pattern, a different plugin. The winner is arbitrated below. + contenders = append(contenders, matches[i]) } } if chosen == nil { @@ -58,6 +72,13 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { "no plugin serves this path") return } + if len(contenders) > 1 { + resolved := g.resolveContestedRoute(w, ps, chosen.Path, contenders) + if resolved == nil { + return // 409 already written, naming the candidates + } + chosen = resolved + } if !strings.EqualFold(r.Method, chosen.Method) { w.Header().Set("Allow", chosen.Method) @@ -98,6 +119,44 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) { lua.WriteHandlerResponse(w, resp) } +// resolveContestedRoute arbitrates a service path that more than one plugin +// declared (user requirement A). It returns the winning handler, or nil after +// writing a 409 when the operator has not yet chosen. +// +// This is the crux of the "report, don't crash, don't silently pick" rule: with +// two plugins both serving e.g. "stats", load order used to decide the winner. +// Now an unresolved contest is a 409 that NAMES the candidates, and the operator +// picks one in the WebUI (POST /api/plugins/conflicts). A recorded decision is +// honoured here on every request thereafter. +func (g *Gateway) resolveContestedRoute(w http.ResponseWriter, ps *lua.Plugins, path string, contenders []lua.HTTPHandler) *lua.HTTPHandler { + winner := ps.RouteWinner(path) + if winner != "" { + for i := range contenders { + if contenders[i].Plugin == winner { + return &contenders[i] + } + } + // A decision exists but points at a plugin that no longer serves this + // path (it was removed or edited). Fall through to 409 rather than honour + // a stale winner that cannot answer. + } + names := make([]string, 0, len(contenders)) + for i := range contenders { + names = append(names, contenders[i].Plugin) + } + sort.Strings(names) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "error": map[string]interface{}{ + "type": "route_conflict", + "message": "service path \"" + path + "\" is claimed by " + strconv.Itoa(len(names)) + " plugins (" + strings.Join(names, ", ") + "); an operator must choose one in the admin UI (Conflicts) or POST /api/plugins/conflicts", + "path": path, + "candidates": names, + }, + }) + return nil +} + // handlerAccessHint explains, in the refusal, exactly which declaration would // open this endpoint. A 403 with no reason is the kind of thing an operator // opens the source to work out, and then the fix is a guess. diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 87b88f3..6ea786c 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -82,6 +82,57 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/plugins") path = strings.Trim(path, "/") + // /api/plugins/conflicts — the arbitration surface (user requirement). + // GET lists every contested route (A) and every suspended adapter-name + // install (B), each with its candidates/incumbent and any recorded + // decision. + // POST {kind,path|name,winner} records the operator's choice and persists + // it. Must be routed BEFORE the single-segment {name} handler below, + // which would otherwise read "conflicts" as a plugin name. + if path == "conflicts" { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]interface{}{ + "routes": ps.RouteConflicts(), + "adapters": ps.AdapterConflicts(), + }) + case http.MethodPost: + var body struct { + Kind string `json:"kind"` // "route" or "adapter" + Path string `json:"path"` + Name string `json:"name"` + Winner string `json:"winner"` // route: plugin name; adapter: "incumbent"|"candidate" + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + switch body.Kind { + case "route": + if err := ps.ResolveRouteConflict(body.Path, body.Winner); err != nil { + writeError(w, http.StatusBadRequest, "conflict_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "path": body.Path, "winner": body.Winner}) + case "adapter": + if err := ps.ResolveAdapterConflict(body.Name, body.Winner); err != nil { + writeError(w, http.StatusBadRequest, "conflict_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name, "winner": body.Winner}) + default: + writeError(w, http.StatusBadRequest, "invalid_request", "kind must be \"route\" or \"adapter\"") + } + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "") + } + return + } + // /api/plugins/{name}/state if strings.HasSuffix(path, "/state") { name := strings.TrimSuffix(path, "/state") diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 69aec6d..f84d2da 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -4856,6 +4856,14 @@ function afterChainEdit() { // on_disk rather than the loaded set so a plugin that FAILED to load // still appears, with its error — otherwise a syntax error looks // identical to "the plugin is not there". + async function resolveRoute(path, winner) { + await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "route", path, winner }) }); + renderPlugins(); + } + async function resolveAdapter(name, winner) { + await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "adapter", name, winner }) }); + renderPlugins(); + } async function renderPlugins() { let j; try { @@ -4908,6 +4916,32 @@ function afterChainEdit() { }) .join(""); + // 冲突裁决(用户要求:服务路径/适配器名冲突不崩溃不拒启,运维在此选一保留) + let confHtml = ""; + try { + const cj = await api("/api/plugins/conflicts"); + const rc = cj.routes || []; + const ac = cj.adapters || []; + if (rc.length || ac.length) { + const rows2 = [] + .concat(rc.map((c) => ` +
${esc(c.path)}${esc(c.name)}两个插件/适配器声明了同一条服务路径或同一个适配器名。未裁决的路径返回 409,撞名的适配器由现任继续服务。请选一个保留。
+| 冲突项 | 候选 | 状态 | 裁决 |
|---|
| ${t("tName")} | ${t("plState")} | ${t("plStages")} | ${t("plVisibility")} | ${t("plEndpoints")} | ${t("plKernel")} | diff --git a/internal/lua/conflict_arbitration_test.go b/internal/lua/conflict_arbitration_test.go new file mode 100644 index 0000000..c5185ed --- /dev/null +++ b/internal/lua/conflict_arbitration_test.go @@ -0,0 +1,287 @@ +package lua + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRouteConflictDetectedAndArbitrated(用户要求 A): +// 两个插件声明同一条 /api/plugins/http/ 服务路径 → 必须报冲突(列出双方), +// 运维裁决后裁决生效;裁决持久化,重启(重新 LoadDir)后仍然生效。 +// 全程不崩溃、不拒绝加载 —— 两个插件都保持 loaded。 +func TestRouteConflictDetectedAndArbitrated(t *testing.T) { + dir := t.TempDir() + _, ps := bridgeVM(t) + ps.dir = dir + + // a.lua 与 b.lua 都声明路径 "shared" + const aSrc = ` +local P = {} +P.name = "plugA" +P.version = "1" +P.exports = {} +P.http = { { path = "shared", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = { from = "A" } } end +return P +` + const bSrc = ` +local P = {} +P.name = "plugB" +P.version = "1" +P.exports = {} +P.http = { { path = "shared", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = { from = "B" } } end +return P +` + for name, src := range map[string]string{"plugA": aSrc, "plugB": bSrc} { + if err := ps.LoadSource(name, src); err != nil { + t.Fatalf("加载 %s: %v", name, err) + } + } + + // ① 冲突被报出来,候选齐整,未裁决 + cs := ps.RouteConflicts() + if len(cs) != 1 { + t.Fatalf("应恰有 1 条路径冲突, 实得 %d: %+v", len(cs), cs) + } + c := cs[0] + if c.Path != "shared" { + t.Fatalf("冲突路径应为 shared, 实得 %q", c.Path) + } + if len(c.Candidates) != 2 || c.Candidates[0] != "plugA" || c.Candidates[1] != "plugB" { + t.Fatalf("候选应为 [plugA plugB], 实得 %v", c.Candidates) + } + if c.Resolved != "" { + t.Fatalf("初始应未裁决, 实得 %q", c.Resolved) + } + // 两个插件都保持 loaded(不许因冲突拒绝启动/加载) + if ps.List()[0]["loaded"] != true || ps.List()[1]["loaded"] != true { + t.Fatal("冲突的两个插件都应保持 loaded") + } + + // ② 非法裁决被拒(赢家必须确实是候选之一) + if err := ps.ResolveRouteConflict("shared", "plugC"); err == nil { + t.Fatal("赢家不是候选应被拒绝") + } + if err := ps.ResolveRouteConflict("other", "plugA"); err == nil { + t.Fatal("未冲突的路径不应可裁决") + } + + // ③ 合法裁决 + 持久化 + if err := ps.ResolveRouteConflict("shared", "plugB"); err != nil { + t.Fatalf("合法裁决失败: %v", err) + } + if ps.RouteWinner("shared") != "plugB" { + t.Fatalf("裁决后赢家应为 plugB, 实得 %q", ps.RouteWinner("shared")) + } + if _, err := os.Stat(filepath.Join(dir, ".route-conflicts.json")); err != nil { + t.Fatalf("裁决应落盘 .route-conflicts.json: %v", err) + } + + // ④ 重启语义:新 registry 读回裁决 + _, ps2 := bridgeVM(t) + ps2.dir = dir + if err := ps2.LoadRouteConflicts(); err != nil { + t.Fatalf("读回裁决: %v", err) + } + if err := ps2.LoadSource("plugA", aSrc); err != nil { + t.Fatal(err) + } + if err := ps2.LoadSource("plugB", bSrc); err != nil { + t.Fatal(err) + } + cs2 := ps2.RouteConflicts() + if len(cs2) != 1 || cs2[0].Resolved != "plugB" { + t.Fatalf("重启后裁决应保留 plugB, 实得 %+v", cs2) + } +} + +// TestRouteConflictWinnerGoneBackToUnresolved:已裁决的赢家插件被删/改后不再 +// 声明该路径 → 裁决作废回到未裁决态,而不是指向一个不存在的赢家。 +func TestRouteConflictWinnerGoneBackToUnresolved(t *testing.T) { + _, ps := bridgeVM(t) + const aSrc = ` +local P = {} +P.name = "plugA" +P.http = { { path = "s", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = {} } end +return P +` + const bSrc = ` +local P = {} +P.name = "plugB" +P.http = { { path = "s", handler = "h", roles = {"admin"} } } +function P.h(req) return { status = 200, json = {} } end +return P +` + if err := ps.LoadSource("plugA", aSrc); err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("plugB", bSrc); err != nil { + t.Fatal(err) + } + if err := ps.ResolveRouteConflict("s", "plugA"); err != nil { + t.Fatal(err) + } + // plugA 被禁用 → 不再占路由 → 冲突消失(不是回未裁决) + if err := ps.SetEnabled("plugA", false); err != nil { + t.Fatal(err) + } + if got := ps.RouteConflicts(); len(got) != 0 { + t.Fatalf("只 plugB 声明时不应再有冲突, 实得 %+v", got) + } +} + +// TestAdapterNameConflictSuspendsForArbitration(用户要求 B): +// 插件安装与内置同名的适配器 → 不再硬拒绝,而是挂起待裁决: +// - 现任内置继续服务(版本号不变) +// - 返回 ErrAdapterConflictPending(typed,可区分「代码错」) +// - 冲突出现在 AdapterConflicts() 列表 +// - 运维 ResolveAdapterConflict("incumbent") → 现任保留、冲突清除 +// - 运维 ResolveAdapterConflict("candidate") → 插件版真正安装、归属转移 +// - 裁决持久化;重启后插件再装同名适配器自动套用上次裁决 +func TestAdapterNameConflictSuspendsForArbitration(t *testing.T) { + dir := t.TempDir() + _, ps := bridgeVM(t) + ps.dir = dir + + builtin := `local a={} a.name="openai" a.version="BUILTIN" a.endpoint="/x" return a` + if err := ps.vm.LoadAdapterSource("openai", builtin); err != nil { + t.Fatal(err) + } + evil := `local a={} a.name="openai" a.version="PLUGIN_V1" a.endpoint="/x" return a` + + // ① 安装挂起:现任保留,typed 错误,不静默覆盖 + got, err := ps.InstallAdapter("sneaky", "openai", evil) + if err == nil { + t.Fatal("撞内置名应返回错误(挂起)") + } + var pend *ErrAdapterConflictPending + if !errors.As(err, &pend) { + t.Fatalf("错误类型应为 ErrAdapterConflictPending, got %T: %v", err, err) + } + if got != "" { + t.Fatalf("挂起时不得返回生效名, 实得 %q", got) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("现任内置必须继续服务, 版本实得 %q", v) + } + + // ② 冲突可见 + ac := ps.AdapterConflicts() + if len(ac) != 1 || ac[0].Name != "openai" || ac[0].Candidate != "sneaky" { + t.Fatalf("挂起冲突应可见: %+v", ac) + } + + // ③ 裁决 incumbent → 现任保留 + if err := ps.ResolveAdapterConflict("openai", "incumbent"); err != nil { + t.Fatalf("裁决 incumbent: %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("裁决 incumbent 后内置应保留, 实得 %q", v) + } + if got := ps.AdapterConflicts(); len(got) != 0 { + t.Fatalf("裁决后冲突应清除, 实得 %+v", got) + } + // 归属仍是 operator(内置) + if o := ps.AdapterOwnerOf("openai"); o != "" { + t.Fatalf("内置归属应为空(operator), 实得 %q", o) + } + + // ④ 重启后插件再装 → 之前的 incumbent 裁决自动套用(不再二次挂起,也不安装) + if err := ps.LoadRouteConflicts(); err != nil { + t.Fatal(err) + } + _, err = ps.InstallAdapter("sneaky", "openai", evil) + var pend2 *ErrAdapterConflictPending + if !errors.As(err, &pend2) { + t.Fatalf("自动套用 incumbent 裁决应仍返回 pending 错误(未安装), got %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" { + t.Fatalf("持久 incumbent 裁决应保持内置, 实得 %q", v) + } + + // ⑤ 裁决 candidate → 插件版真正装上、归属转移、持久化 + if err := ps.ResolveAdapterConflict("openai", "candidate"); err != nil { + t.Fatalf("裁决 candidate: %v", err) + } + if v := ps.vm.AdapterVersion("openai"); v != "PLUGIN_V1" { + t.Fatalf("裁决 candidate 后插件版应生效, 实得 %q", v) + } + if o := ps.AdapterOwnerOf("openai"); o != "sneaky" { + t.Fatalf("归属应转移给 sneaky, 实得 %q", o) + } + // 持久化文件里是 candidate:sneaky + b, _ := os.ReadFile(filepath.Join(dir, ".route-conflicts.json")) + if !strings.Contains(string(b), "candidate:sneaky") { + t.Fatalf("裁决应持久化为 candidate:sneaky, 文件: %s", b) + } +} + +// TestAdapterPluginConflictSuspendsForArbitration:插件 A 装了适配器后插件 B +// 再装同名 → 挂起待裁决(与内置撞名同一机制),现任 A 的版本继续服务。 +func TestAdapterPluginConflictSuspendsForArbitration(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + _, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2) + var pend *ErrAdapterConflictPending + if !errors.As(err, &pend) { + t.Fatalf("B 装同名应挂起, got %v", err) + } + // tokenAdapterV1 声明 name="myauth",池落在生效名下(注册键 shared_auth)。 + if v := ps.vm.AdapterVersion("myauth"); v != "1.0" { + t.Fatalf("alpha 的现任版本应继续服务, 实得 %q", v) + } + ac := ps.AdapterConflicts() + if len(ac) != 1 || ac[0].Incumbent != "plugin:alpha" { + t.Fatalf("冲突应点名现任归属 plugin:alpha, 实得 %+v", ac) + } + // 裁决 candidate → beta 接管 + if err := ps.ResolveAdapterConflict("shared_auth", "candidate"); err != nil { + t.Fatal(err) + } + if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { + t.Fatalf("beta 版应生效, 实得 %q", v) + } + if o := ps.AdapterOwnerOf("myauth"); o != "beta" { + t.Fatalf("归属应转移给 beta, 实得 %q", o) + } +} + +// TestAdapterConflictRejectsBadWinner:裁决值必须合法;无挂起冲突时拒绝。 +func TestAdapterConflictRejectsBadWinner(t *testing.T) { + _, ps := bridgeVM(t) + if err := ps.ResolveAdapterConflict("nope", "candidate"); err == nil { + t.Fatal("无挂起冲突应拒绝") + } + if _, err := ps.InstallAdapter("p", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + // myauth 是插件自己装的 → 更新路径(update 走 UpdateAdapter,非冲突) + if err := ps.ResolveAdapterConflict("myauth", "bogus"); err == nil { + t.Fatal("非法 winner 值应拒绝") + } +} + +// TestPluginOwnUpdateStillWorks:插件更新自己装的适配器(token 轮换)不走冲突 +// 路径 —— 行为与改造前一致。守住「挂起只发生在撞名」这条边界。 +func TestPluginOwnUpdateStillWorks(t *testing.T) { + _, ps := bridgeVM(t) + if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil { + t.Fatal(err) + } + if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil { + t.Fatalf("own update 不应受影响: %v", err) + } + if v := ps.vm.AdapterVersion("myauth"); v != "2.0" { + t.Fatalf("轮换后版本应 2.0, 实得 %q", v) + } + if got := ps.AdapterConflicts(); len(got) != 0 { + t.Fatalf("own update 不应产生冲突, 实得 %+v", got) + } +} diff --git a/internal/lua/plugin_adapter_bridge.go b/internal/lua/plugin_adapter_bridge.go index d31d38c..2554fd4 100644 --- a/internal/lua/plugin_adapter_bridge.go +++ b/internal/lua/plugin_adapter_bridge.go @@ -26,6 +26,7 @@ package lua import ( "fmt" "sort" + "strings" "sync" golua "github.com/aarzilli/golua/lua" @@ -103,35 +104,65 @@ func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) { if ps.vm == nil { return "", fmt.Errorf("install_adapter: no adapter VM available") } - // Rule 3 first: an adapter another plugin already owns is off-limits. + // A name conflict used to be a hard rejection (owned by another plugin, or + // shadows a built-in). The user now wants it SUSPENDED for operator + // arbitration instead of refused outright: the incumbent keeps serving, the + // candidate waits in the conflicts list, and the operator picks a winner in + // the WebUI. The install still returns a (typed) error so the caller knows it + // did not take effect — but it is ErrAdapterConflictPending, not a dead end. + incumbent := "" if owner := ps.adapterOwner().ownedBy(name); owner != "" && owner != plugin { - return "", fmt.Errorf("install_adapter: adapter %q is owned by plugin %q and cannot be replaced by %q", - name, owner, plugin) + incumbent = "plugin:" + owner + } else if containsNameFold(ps.vm.bundledNames(), name) { + incumbent = "operator (built-in)" } - // Rules 1+2 (path safety + no shadowing built-ins) live in - // InstallPluginAdapter so there is exactly one enforcement point. + if incumbent != "" { + if ps.recordAdapterConflict(name, incumbent, plugin, code) { + // The operator already chose THIS candidate in a prior session. Install + // for real via LoadAdapterSource, which bypasses the reserved-name gate + // — that explicit operator override is exactly what the arbitration is + // FOR, and re-suspending it on every restart would be a loop. + if err := ps.vm.LoadAdapterSource(name, code); err != nil { + return "", err + } + return ps.finishInstall(plugin, name), nil + } + return "", &ErrAdapterConflictPending{Name: name, Incumbent: incumbent} + } + // No conflict: normal install path. Rules 1+2 (path safety + no shadowing + // built-ins) live in InstallPluginAdapter so there is exactly one + // enforcement point. (The shadow check there is now unreachable for a + // genuine built-in — it is caught above and suspended — but kept as the + // single authority for path safety and any future reserved set.) if err := ps.vm.InstallPluginAdapter(name, code); err != nil { return "", err } - // Ownership is recorded under the name sources will actually reference. A - // script may declare a name different from the registration key, and it is - // the declared name that ends up in the registry — recording it under the - // key would leave the adapter un-owned and therefore mutable by anyone. - // Ownership is recorded under the name sources will actually reference. A - // script may declare a name different from the registration key, and it is - // the declared name that ends up in the registry — recording it under the - // key would leave the adapter un-owned and therefore mutable by anyone. - // - // Both names are recorded: the registration key so a later update through - // the same key still matches, and the declared name so a lookup by the name - // a source references also matches. Both entries are cleared together on - // remove. + return ps.finishInstall(plugin, name), nil +} + +// finishInstall records adapter ownership under BOTH the registration key and +// the declared name (a script may declare a name different from the key, and it +// is the declared name sources reference — recording only the key would leave +// the adapter un-owned and therefore mutable by anyone). Both entries are +// cleared together on remove. +func (ps *Plugins) finishInstall(plugin, name string) string { effective := ps.vm.EffectiveAdapterName(name) ps.adapterOwner().set(effective, plugin) if effective != name { ps.adapterOwner().set(name, plugin) } - return effective, nil + return effective +} + +// containsNameFold reports whether name is in names (case-insensitive, matching +// ValidateAdapterName's reserved-name comparison). +func containsNameFold(names []string, name string) bool { + for _, n := range names { + if strings.EqualFold(n, name) { + return true + } + } + return false } // UpdateAdapter replaces an adapter a plugin previously installed — the token diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 6adb017..45126cc 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -292,6 +292,23 @@ type Plugins struct { // is state and not a convention). adapterOwn *adapterOwnership adapterOwnOnce sync.Once + // routeWinners records the operator's arbitration for contested HTTP service + // paths: a path two+ plugins declared, resolved to one winning plugin. Keyed + // by normalized path. See route_conflicts.go — a contested path that is NOT + // in here is unresolved and the dispatcher returns 409 rather than silently + // picking a load-order winner. Persisted in .route-conflicts.json. + routeWinners map[string]string + // adapterWinners records the operator's arbitration for an adapter NAME two + // things claimed (user requirement B): "incumbent" keeps the built-in / first + // plugin, "candidate: |
|---|