mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): remove_source 漏声明(删了账号源还在对外服务)
## 真实缺陷(用户可见)
`plugin.kernel` 只声明了 5 个内核动作,而插件在 **5 处**调用 `remove_source`
—— 声明与使用**漂移**。生产按 declared 授予 ⇒ **撤源全部被拒**。
更坏的是它**静默**:`Core.AddSource` 是**同名 upsert**,所以 `syncAccountSource`
里"先 remove 再 add"的 remove 失败**看不出来**(add 会覆盖同名源)——
只有**纯删除路径**才暴露:
• `dropAccountSource`(账号停用/删除)→ **账号删了,它的源还在对外服务**
• `account_delete` 收尾清旧单账号源
• 两处 `channels-<plat>` 旧源清理
我实测确认:只授 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。
This commit is contained in:
@ -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 字段(改了字段不代表路由变了)。
|
||||
|
||||
Reference in New Issue
Block a user