From 00db19eb77952488acb6c1558c030da39a668451 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 10:04:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20remove=5Fsource=20=E6=BC=8F?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=EF=BC=88=E5=88=A0=E4=BA=86=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=BA=90=E8=BF=98=E5=9C=A8=E5=AF=B9=E5=A4=96=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 真实缺陷(用户可见) `plugin.kernel` 只声明了 5 个内核动作,而插件在 **5 处**调用 `remove_source` —— 声明与使用**漂移**。生产按 declared 授予 ⇒ **撤源全部被拒**。 更坏的是它**静默**:`Core.AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里"先 remove 再 add"的 remove 失败**看不出来**(add 会覆盖同名源)—— 只有**纯删除路径**才暴露: • `dropAccountSource`(账号停用/删除)→ **账号删了,它的源还在对外服务** • `account_delete` 收尾清旧单账号源 • 两处 `channels-` 旧源清理 我实测确认:只授 add_source 时删账号,其源**仍在**内核源列表里。 ## 修法 ① 声明补上 `remove_source`(并加注释说明"加动作时同时改这里")。 ② `dropAccountSource` / `syncAccountSource` 的撤源**不再忽略返回值**, 失败**记进事件日志**(面板可见,附可操作提示)—— "一个被丢弃的返回值"不该是权限缺失的唯一痕迹。 ③ 判据 `TestChannelsDeclaresEveryKernelActionItCalls`:**静态扫描**源码里所有 `__llmsproxy_kernel.call("X")`,要求每个 X 都在 `plugin.kernel` 里声明。 将来任何人加了内核调用却忘了声明,会在这里报红,而不是等线上静默失效。 ## 判据(3 条 + 变异 3/3) - 授予 remove_source 时:删账号**确实撤掉源** - 未授予时:**必须可见地失败**(事件日志含"撤源失败") - 声明必须覆盖全部实际调用 变异:忽略返回值 → 报红;漏声明 remove_source → 报红。 ## 部署后需补的一次操作 生产权限集当前只有 add_source ⇒ 需授予 remove_source(否则既有账号的 "停用/删除"仍撤不掉源)。已在部署后手工授予,并复核 declared == granted。 --- .../core/channels_remove_source_perm_test.go | 208 ++++++++++++++++++ internal/lua/plugins/channels.lua | 63 +++++- 2 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 internal/core/channels_remove_source_perm_test.go diff --git a/internal/core/channels_remove_source_perm_test.go b/internal/core/channels_remove_source_perm_test.go new file mode 100644 index 0000000..fedc6ee --- /dev/null +++ b/internal/core/channels_remove_source_perm_test.go @@ -0,0 +1,208 @@ +package core + +import ( + "regexp" + "sort" + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。 +// +// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在 +// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource` +// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是 +// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。 +// +// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里 +// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)—— +// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。 +func TestAccountDeleteRemovesItsSource(t *testing.T) { + t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) { + h := newRemoveSourceHarness(t, true) + h.addAccount("del-a") + if got := h.sourceNames(); !hasName(got, "channels-buddy-del-a") { + t.Fatalf("前置条件:账号源应已建立,实得 %v", got) + } + h.deleteAccount("del-a") + if got := h.sourceNames(); hasName(got, "channels-buddy-del-a") { + t.Fatalf("账号已删但源仍在 ⇒ 已删账号继续对外服务:%v", got) + } + t.Log("✓ 删除账号同时撤掉其源") + }) + + t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) { + h := newRemoveSourceHarness(t, false) + h.addAccount("del-b") + h.deleteAccount("del-b") + // 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的 + // 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。 + logs := h.eventLog() + joined := strings.Join(logs, " | ") + if !strings.Contains(joined, "撤源失败") { + t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+ + "「删了账号却还在用」)\n事件日志: %s", joined) + } + if !strings.Contains(joined, "remove_source") { + t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined) + } + t.Logf("✓ 撤源失败已可见: %s", joined) + }) +} + +// ── harness ── + +type removeSourceHarness struct { + t *testing.T + ps *lua.Plugins +} + +func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness { + t.Helper() + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + // ★ 照生产现状:add_source 有、remove_source 视参数而定 + // 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项) + actions := []string{"add_source", "list_sources"} + if grantRemove { + actions = append(actions, "remove_source") + } + _ = ps.SetPluginKernelPermission("channels", actions, false) + return &removeSourceHarness{t: t, ps: ps} +} + +func (h *removeSourceHarness) addAccount(id string) { + h.t.Helper() + if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "buddy", "account_id": id, + "credential": `{"access_token":"AT","uid":"u1"}`, + }); err != nil { + h.t.Fatalf("account_add(%s): %v", id, err) + } +} + +func (h *removeSourceHarness) deleteAccount(id string) { + h.t.Helper() + if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{ + "platform": "buddy", "account_id": id, + }); err != nil { + h.t.Fatalf("account_delete(%s): %v", id, err) + } +} + +func (h *removeSourceHarness) sourceNames() []string { + h.t.Helper() + res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) + if err != nil { + h.t.Fatal(err) + } + j := jsonOf(h.t, res) + var out []string + arr, _ := j["sources"].([]interface{}) + for _, e := range arr { + if m, ok := e.(map[string]interface{}); ok { + if n, ok := m["name"].(string); ok { + out = append(out, n) + } + } + } + return out +} + +// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。 +// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里, +// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。 +func (h *removeSourceHarness) eventLog() []string { + h.t.Helper() + res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{}) + if err != nil { + h.t.Fatal(err) + } + j := jsonOf(h.t, res) + var out []string + arr, _ := j["log"].([]interface{}) + for _, e := range arr { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out +} + +func hasName(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} + +// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。 +// +// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source` +// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为 +// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败), +// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。 +// +// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人 +// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。 +func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + + // ① 抽出声明清单 + declStart := strings.Index(s, "plugin.kernel = {") + if declStart < 0 { + t.Fatal("找不到 plugin.kernel 声明(判据需更新)") + } + declEnd := strings.Index(s[declStart:], "}") + if declEnd < 0 { + t.Fatal("plugin.kernel 声明没有收尾大括号") + } + declBlob := s[declStart : declStart+declEnd] + declared := map[string]bool{} + for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) { + declared[m[1]] = true + } + if len(declared) == 0 { + t.Fatal("声明清单解析为空(判据失效)") + } + + // ② 抽出实际调用的动作 + called := map[string]bool{} + for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) { + called[m[1]] = true + } + if len(called) == 0 { + t.Fatal("没扫描到任何内核调用(判据失效)") + } + + // ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒) + var missing []string + for a := range called { + if !declared[a] { + missing = append(missing, a) + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Fatalf("插件调用了未声明的内核动作 %v\n"+ + "(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+ + "的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)", + missing) + } + t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called)) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 0b1599b..81605e9 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -48,11 +48,23 @@ local plugin = { } -- 申请的内核权限。设置页会把"申请中但未授权"标红。 -plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" } +-- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。 +-- +-- ★ 这份清单必须与实际调用**逐一对齐**:`remove_source` 曾被漏声明, +-- 而插件在 5 处调用它(syncAccountSource 的重建、dropAccountSource、 +-- account_delete 收尾、两处清旧单账号源)⇒ 生产只授了 add_source, +-- 于是**撤源全部被拒**。更坏的是它**静默**:AddSource 是**同名 upsert**, +-- 所以"先 remove 再 add"的 remove 失败看不出来,只有**纯删除路径** +-- (删账号/停用)才暴露 —— 表现是「账号删了,它的源还在对外服务」。 +-- ⇒ 加动作时**同时**改这里;判据见 channels_kernel_declaration_test.go。 +plugin.kernel = { + "add_source", "remove_source", "list_sources", + "probe_all", "reset_health", "status", +} -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -3638,8 +3650,20 @@ local function syncAccountSource(provider, accountId) models[#models+1] = { id = m, kind = "chat", priority = 20 } end end - -- 重建:先 remove(容错:源可能不存在)再 add - __llmsproxy_kernel.call("remove_source", { name = name }) + -- 重建:先 remove(容错:源可能不存在)再 add。 + -- ⚠ 这里失败**可容忍**:AddSource 是**同名 upsert**,add 会把旧的覆盖掉。 + -- 但这正是它掩盖 remove_source 权限缺失的原因(纯删除路径才暴露), + -- 故失败仍记一笔(低频,不会刷屏)。 + local rr = __llmsproxy_kernel.call("remove_source", { name = name }) + if not (rr and rr.ok) then + local l = plugin.state.log + local last = type(l) == "table" and l[1] and l[1].msg or "" + -- 只在**上一次不是同一条**时记,避免每次同步都刷一条 + if not tostring(last):find("撤源(重建)失败", 1, true) then + logEvent("撤源(重建)失败 " .. tostring(name) .. ": " + .. tostring(rr and (rr.error or rr.err) or "无返回")) + end + end local src = { name = name, base_url = plat.base_url, @@ -3678,8 +3702,26 @@ local function syncAccountSource(provider, accountId) end end +-- dropAccountSource:把某账号的上游源撤掉(账号停用/删除时调用)。 +-- +-- ★ 必须**检查返回值**:内核的 remove_source 需要**单独授予**(它是 Destructive 级, +-- 不在 add_source 的授权里)。生产权限集曾只有 add_source ⇒ 这个调用被**拒绝**, +-- 而调用方**忽略返回值** ⇒ 静默失败的正确后果是:**账号已删/已停用,它的源仍在 +-- 对外服务**(用户看到"删了却还在用")。 +-- 更隐蔽的是:AddSource 是**同名 upsert**,所以 syncAccountSource 里"先 remove 再 add" +-- 的 remove 失败**看不出来**(add 会把同名覆盖掉)—— 只有纯删除路径才暴露。 +-- 故这里把失败**记进事件日志**,让它在面板上可见,而不是只存在于一个被丢弃的返回值里。 local function dropAccountSource(provider, accountId) - __llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) }) + local name = accountSourceName(provider, accountId) + local r = __llmsproxy_kernel.call("remove_source", { name = name }) + local ok = r and r.ok + if not ok then + local err = (r and (r.error or r.err)) or "无返回" + logEvent("撤源失败 " .. tostring(name) .. ": " .. tostring(err) + .. "(若提示 not granted,需给 channels 插件授予 remove_source 权限)") + return false, err + end + return true end -- ============================================================================ @@ -11292,6 +11334,17 @@ function plugin.__resync_sources_probe(args) return { json = { ok = true, n = n } } end +-- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。 +-- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、 +-- 不在 exports 里),判据要断言"某件事被记录了"就得有它。 +function plugin.__event_log_probe() + local out = {} + for _, e in ipairs(plugin.state.log or {}) do + if type(e) == "table" and type(e.msg) == "string" then out[#out + 1] = e.msg end + end + return { json = { log = out } } +end + -- plugin.__list_sources_probe:只给测试/诊断用 —— 内核里当前登记的源**及其顺序**。 -- 顺序就是内核实际尝试的顺序(candidates 按注册序),故“重排是否生效”必须看它, -- 而不是看 pool 里的 priority 字段(改了字段不代表路由变了)。