mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed954b77bd | |||
| cb3ea25586 | |||
| 0232b9cd24 | |||
| a07449d32e | |||
| 84a9fccd9a |
49
deploy.sh
49
deploy.sh
@ -31,6 +31,7 @@ set -euo pipefail
|
||||
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
|
||||
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
|
||||
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
|
||||
readonly TARGET_PLUGINS="/etc/llmsproxy/plugins"
|
||||
readonly REPO_DIR="/home/program/llmsproxy"
|
||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||
readonly BIN_NAME="llmsproxy"
|
||||
@ -390,6 +391,52 @@ sync_adapters() {
|
||||
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
|
||||
}
|
||||
|
||||
# 同步内置插件到 plugin_dir。
|
||||
#
|
||||
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
|
||||
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
|
||||
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
|
||||
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
|
||||
#
|
||||
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
|
||||
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
|
||||
sync_plugins() {
|
||||
log "同步内置插件"
|
||||
|
||||
SRC_PLUGINS="$REPO_DIR/internal/lua/plugins"
|
||||
[[ -d "$SRC_PLUGINS" ]] || fail "内嵌插件目录不存在: $SRC_PLUGINS"
|
||||
|
||||
mkdir -p "$TARGET_PLUGINS"
|
||||
|
||||
# 备份将被触及的文件(只会是本次新增的那些)
|
||||
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
||||
local added=0
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
|
||||
continue # 已存在:运维版本优先,不覆盖
|
||||
fi
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " + 新增内置插件: $name"
|
||||
((added++)) || true
|
||||
done
|
||||
if (( added == 0 )); then
|
||||
log " 无新增内置插件(均已存在)"
|
||||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
||||
done
|
||||
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
|
||||
}
|
||||
|
||||
# ---------- 重启服务 ----------
|
||||
restart_service() {
|
||||
log "重启服务(中断 <500ms)"
|
||||
@ -495,6 +542,7 @@ print_summary() {
|
||||
log " 二进制: $TARGET_BIN"
|
||||
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
|
||||
log " 适配器: $TARGET_ADAPTERS"
|
||||
log " 插件: $TARGET_PLUGINS"
|
||||
log "═════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
@ -511,6 +559,7 @@ main() {
|
||||
verify_master_key
|
||||
build
|
||||
sync_adapters
|
||||
sync_plugins
|
||||
backup_old_state
|
||||
atomic_replace_binary
|
||||
atomic_replace_config
|
||||
|
||||
@ -260,6 +260,38 @@ func (h *buddyHarness) begin() (map[string]interface{}, string) {
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
|
||||
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
|
||||
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string), aid
|
||||
}
|
||||
|
||||
func (h *buddyHarness) accountList() []interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
accts, _ := m["accounts"].([]interface{})
|
||||
return accts
|
||||
}
|
||||
func (h *buddyHarness) poll(id string) map[string]interface{} {
|
||||
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
@ -287,9 +319,17 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
|
||||
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
|
||||
}
|
||||
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
|
||||
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
|
||||
if v != "BD-AT-1" {
|
||||
t.Fatalf("凭据应 BD-AT-1, 实得 %q", v)
|
||||
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
|
||||
t.Fatalf("凭据应含 access_token, 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, "buddy-dev") {
|
||||
t.Fatalf("凭据应回填昵称, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
|
||||
@ -303,6 +343,46 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
t.Fatalf("account 失败不应阻塞: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
|
||||
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
|
||||
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
|
||||
// has_credential=false、credits 报「无凭据」——即用户报障的
|
||||
// 「登录了但无法获取账号信息」。
|
||||
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
_, sid, aid := h.beginPool()
|
||||
h.poll(sid) // 首拍 11217
|
||||
j := h.poll(sid)
|
||||
if j["state"] != "completed" {
|
||||
t.Fatalf("池路径登录应完成: %v", j)
|
||||
}
|
||||
// ① 凭据必须落在池账号的 ref 上
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("池凭据应是完整 JSON: %q", v)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
|
||||
found := false
|
||||
for _, raw := range h.accountList() {
|
||||
a, _ := raw.(map[string]interface{})
|
||||
if a["id"] == aid {
|
||||
found = true
|
||||
if a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
if a["refreshable"] != true {
|
||||
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("池账号 %s 未出现在 account_list", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
@ -5,6 +5,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
||||
@ -320,3 +324,47 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
|
||||
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
|
||||
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
|
||||
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 把 buddy 的余额/签到端点指到本地假上游
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("余额请求未发出")
|
||||
}
|
||||
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
|
||||
want := map[string]string{
|
||||
"Authorization": "Bearer BD-AT",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-Product": "SaaS",
|
||||
"X-Product-Code": "codebuddy",
|
||||
"User-Agent": "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -451,6 +451,16 @@ func (j jsonPermission) unmarshal() KernelPermission {
|
||||
return KernelPermission{Actions: j.Actions, AllRead: j.AllRead}
|
||||
}
|
||||
|
||||
type jsonPluginPolicy struct {
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
AllRead bool `json:"all_read,omitempty"`
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
func policyFromActions(p KernelPermission) jsonPluginPolicy {
|
||||
return jsonPluginPolicy{Actions: p.Actions, AllRead: p.AllRead}
|
||||
}
|
||||
|
||||
// KernelActionView is one action as the settings page needs it: the catalog entry
|
||||
// plus whether THIS plugin currently holds it and whether the plugin asked for it.
|
||||
type KernelActionView struct {
|
||||
|
||||
@ -45,7 +45,12 @@ func (ps *Plugins) LoadKernelPermissions() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := map[string]jsonPermission{}
|
||||
// The file holds BOTH kernel-action grants (map[string]jsonPermission, the
|
||||
// historical shape) AND, for plugins that asked for them, the operator's
|
||||
// extra-host grants (map[string]jsonPluginPolicy.Hosts). A bare
|
||||
// {actions:[...]} row still parses into jsonPluginPolicy with empty Hosts,
|
||||
// so old files load unchanged.
|
||||
raw := map[string]jsonPluginPolicy{}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -54,8 +59,17 @@ func (ps *Plugins) LoadKernelPermissions() error {
|
||||
if ps.kernelPerms == nil {
|
||||
ps.kernelPerms = kernelPermissionSet{}
|
||||
}
|
||||
if ps.extraHosts == nil {
|
||||
ps.extraHosts = map[string][]string{}
|
||||
}
|
||||
for name, jp := range raw {
|
||||
ps.kernelPerms[name] = jp.unmarshal()
|
||||
ps.kernelPerms[name] = KernelPermission{Actions: jp.Actions, AllRead: jp.AllRead}
|
||||
if len(jp.Hosts) > 0 {
|
||||
// Load the persisted grant into extraHosts WITHOUT folding it into the
|
||||
// allowlist yet — sources are loaded later, and the caller owns that
|
||||
// fold. GrantExtraHosts rebuilds the allowlist when sources are ready.
|
||||
ps.extraHosts[name] = append([]string(nil), jp.Hosts...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -65,12 +79,17 @@ func (ps *Plugins) LoadKernelPermissions() error {
|
||||
// Grants drop unknown action names on write. If an operator authorized an action
|
||||
// that a later release removed, keeping the name would leave a stale entry that
|
||||
// nothing can ever satisfy — and it would reappear in the UI as if it were live.
|
||||
//
|
||||
// Host grants travel in the same file (jsonPluginPolicy.Hosts): a granted host is
|
||||
// the operator's explicit decision to let a plugin egress somewhere, and losing
|
||||
// it across a restart (the previous behaviour) made grants silently vanish.
|
||||
func (ps *Plugins) saveKernelPermissionsLocked() {
|
||||
path := ps.kernelPermsFile()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
out := map[string]jsonPermission{}
|
||||
out := map[string]jsonPluginPolicy{}
|
||||
something := false
|
||||
for name, perm := range ps.kernelPerms {
|
||||
valid := make([]string, 0, len(perm.Actions))
|
||||
for _, a := range perm.Actions {
|
||||
@ -78,12 +97,28 @@ func (ps *Plugins) saveKernelPermissionsLocked() {
|
||||
valid = append(valid, a)
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 && !perm.AllRead {
|
||||
row := policyFromActions(KernelPermission{Actions: valid, AllRead: perm.AllRead})
|
||||
if hs := ps.extraHosts[name]; len(hs) > 0 {
|
||||
row.Hosts = append([]string(nil), hs...)
|
||||
}
|
||||
if len(valid) == 0 && !perm.AllRead && len(row.Hosts) == 0 {
|
||||
continue // nothing granted: presence would be a lie
|
||||
}
|
||||
out[name] = KernelPermission{Actions: valid, AllRead: perm.AllRead}.marshal()
|
||||
out[name] = row
|
||||
something = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// A plugin may hold host grants with NO kernel actions granted (operators
|
||||
// approve hosts and actions independently). Emit those too.
|
||||
for name, hs := range ps.extraHosts {
|
||||
if len(hs) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[name]; !exists {
|
||||
out[name] = jsonPluginPolicy{Hosts: append([]string(nil), hs...)}
|
||||
something = true
|
||||
}
|
||||
}
|
||||
if !something {
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
|
||||
@ -338,3 +338,67 @@ return plugin
|
||||
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[],
|
||||
// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP
|
||||
// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径
|
||||
// 有、Call 路径有,只有它漏了。后果:
|
||||
//
|
||||
// .channels.state.json 从未被写出 → 重启后账号池静默清空。
|
||||
// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。
|
||||
//
|
||||
// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。
|
||||
// 本测试用**无 hooks、只走 handler** 的插件复现该形态。
|
||||
func TestInvokeHandlerIsPersisted(t *testing.T) {
|
||||
ps, _ := newPersistVM(t)
|
||||
if err := ps.LoadSource("handlersonly", `
|
||||
local plugin = { name = "handlersonly", version = "1.0" }
|
||||
plugin.state = { accounts = {} }
|
||||
-- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态)
|
||||
plugin.hooks = {}
|
||||
plugin.http = {
|
||||
{ path = "add", method = "POST", handler = "add" },
|
||||
}
|
||||
function plugin.add(req)
|
||||
local body = req.body or ""
|
||||
local id = body:match('"id":"([^"]+)"') or "unknown"
|
||||
plugin.state.accounts[id] = { id = id, enabled = true }
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
return plugin
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"acct-1", "acct-2"} {
|
||||
resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{
|
||||
Method: "POST", Body: `{"id":"` + id + `"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("handler returned nil response")
|
||||
}
|
||||
}
|
||||
// Close() waits for the final flush, so no timing is involved: anything the
|
||||
// handlers did NOT mark dirty cannot appear here.
|
||||
ps.Close()
|
||||
|
||||
b, err := os.ReadFile(ps.stateFile("handlersonly"))
|
||||
if err != nil {
|
||||
t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+
|
||||
" —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+
|
||||
" 否则 channels 的账号池会在每次重启后静默清空", err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accts, _ := saved.State["accounts"].(map[string]interface{})
|
||||
if len(accts) != 2 {
|
||||
t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
@ -80,19 +81,39 @@ var httpClient = &http.Client{
|
||||
const httpGlobal = "__llmsproxy_http"
|
||||
|
||||
// httpAllowlist is the set of hosts plugins may contact: the host of every
|
||||
// configured source's base_url.
|
||||
// configured source's base_url, plus every operator-granted extra host.
|
||||
//
|
||||
// It is a LIVE, shared object, not a snapshot. This matters: bootPluginState
|
||||
// binds one *httpAllowlist into every Lua closure at state-boot time, and those
|
||||
// closures live for the plugin's lifetime. Granting an extra host after a
|
||||
// worker booted must therefore MUTATE the same object the closures hold —
|
||||
// replacing ps.allowHTTP with a fresh instance would leave every already-booted
|
||||
// worker checking the STALE list, and a grant would look accepted (the API
|
||||
// answers ok, the UI shows it) while requests kept getting refused with
|
||||
// "not an allowed outbound destination". That exact failure shipped once.
|
||||
//
|
||||
// Mutations are rare (operator approves a host); lookups are per request.
|
||||
type httpAllowlist struct {
|
||||
mu sync.RWMutex
|
||||
hosts map[string]bool
|
||||
}
|
||||
|
||||
func newHTTPAllowlist(hosts []string) *httpAllowlist {
|
||||
m := make(map[string]bool, len(hosts))
|
||||
a := &httpAllowlist{hosts: make(map[string]bool, len(hosts))}
|
||||
a.add(hosts)
|
||||
return a
|
||||
}
|
||||
|
||||
// add merges hosts into the live allowlist. Called by rebuild (source hosts
|
||||
// change on hot reload) and by GrantExtraHosts (operator approves a host).
|
||||
func (a *httpAllowlist) add(hosts []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, h := range hosts {
|
||||
if h != "" {
|
||||
m[strings.ToLower(h)] = true
|
||||
a.hosts[strings.ToLower(h)] = true
|
||||
}
|
||||
}
|
||||
return &httpAllowlist{hosts: m}
|
||||
}
|
||||
|
||||
// allows reports whether host:port may be contacted. Ports 80/443 are implied
|
||||
@ -103,6 +124,8 @@ func (a *httpAllowlist) allows(host string, port string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.hosts[host] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -264,6 +264,10 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
// A handler that raised partway may already have mutated plugin.state
|
||||
// (an account added before a later step failed), so mark it dirty before
|
||||
// bailing: the mutation it managed to make is still real.
|
||||
ps.markDirtyLocked(p)
|
||||
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
@ -273,6 +277,18 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
|
||||
if err := luaToJSON(L, -1, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A handler mutates plugin.state just as a hook or an exported Call does, and
|
||||
// this was the one dispatch path that never marked the plugin dirty.
|
||||
//
|
||||
// Consequence (real defect): a plugin with NO hooks — channels is exactly
|
||||
// that, it declares hooks=[] and drives everything through its own HTTP
|
||||
// routes — accumulated accounts, credential refs, model blacklists and its
|
||||
// event log entirely in memory. Nothing ever marked it dirty, so
|
||||
// .channels.state.json was never written and a restart silently wiped the
|
||||
// account pool: the operator saw "logged in", restarted for an upgrade, and
|
||||
// the account was gone. billing/quota escape this only because they declare
|
||||
// hooks and/or use Call, both of which already mark dirty.
|
||||
ps.markDirtyLocked(p)
|
||||
return parseHandlerResponse(raw)
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,8 @@ package lua
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -216,3 +218,139 @@ return P
|
||||
t.Fatalf("body=%q, want the server's JSON", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantExtraHostsVisibleToAlreadyBootedWorker 是真实事故的回归测试。
|
||||
//
|
||||
// 事故:allowlist 是不可变快照。bootPluginState 在 worker 启动时把 *httpAllowlist
|
||||
// 指针绑进 Lua 闭包;GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**,已启动的
|
||||
// worker 仍持旧指针 → 授权接口返回 ok、UI 显示已批,但插件请求仍被
|
||||
// "not an allowed outbound destination" 拒绝。channels 插件的 auth/state 就是这样
|
||||
// 在生产被拦的(copilot.tencent.com 已批仍报错)。
|
||||
//
|
||||
// 修复:allowlist 改为带锁的**可变共享对象**(原地 add),所有闭包立即可见。
|
||||
// 本测试按事故顺序构造:先加载插件(worker 已绑定 allowlist)→ 再授权 →
|
||||
// 断言**同一批已启动的 worker**立刻能用新主机。
|
||||
func TestGrantExtraHostsVisibleToAlreadyBootedWorker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
ps.SetOutboundHosts(nil) // 起始只允许 loopback
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
// srv.URL 是 127.0.0.1 —— 非回环测试要另想办法;httptest 只能回环,
|
||||
// 所以这里用「允许清单里没有的主机名」→ 通过 SetOutboundHosts 授权它,
|
||||
// 但 srv 本身必须仍回环。做法:插件请求的 URL 用 srv.URL(回环,恒允许)?
|
||||
// 不行——那测不出 allowlist 更新。改用 checkURL 直接断言新主机可见。
|
||||
//
|
||||
// 真实事故的关键路径:worker 已启动 → 授权 → 同一 worker 立即看到。
|
||||
// Lua 层面用回环无法区分(回环永远允许),因此这里的断言打在两层:
|
||||
// ① allowlist 对象层:已存在实例 add 后立即 allows() 新主机;
|
||||
// ② 插件层:SetOutboundHosts 重新折算后,同一 worker 的请求仍工作
|
||||
// (即 rebuild 不会使旧引用失效)。
|
||||
host := "portal.example.com"
|
||||
|
||||
// ① 对象层:不可变快照时代的 bug 复现点
|
||||
allow := newHTTPAllowlist(nil)
|
||||
worker := allow // 模拟 bootPluginState 绑定的指针
|
||||
if _, err := allow.checkURL("https://" + host + "/x"); err == nil {
|
||||
t.Fatal("前提不成立:未授权主机不应允许")
|
||||
}
|
||||
allow.add([]string{host}) // GrantExtraHosts 的实际路径(原地 add)
|
||||
if _, err := worker.checkURL("https://" + host + "/x"); err != nil {
|
||||
t.Fatalf("授权后,已绑定的旧引用仍拒绝新主机(快照 bug 复现): %v", err)
|
||||
}
|
||||
|
||||
// ② 插件层:同一 worker 在 rebuild 后仍可用
|
||||
ps2 := NewPlugins(NewVM(t.TempDir()), t.TempDir())
|
||||
ps2.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
probe := `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = { "probe" }
|
||||
P.hosts = { "portal.example.com" }
|
||||
function P.probe(arg)
|
||||
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
|
||||
if res == nil then return { error = tostring(err) } end
|
||||
return { status = res.status }
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := ps2.LoadSource("grantprobe", probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// worker 已启动。现在授权(grant → rebuild → 原地 add)
|
||||
if err := ps2.GrantExtraHosts("grantprobe", []string{"portal.example.com"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
// 同一批 worker:一次调用成功即证明 allowlist 引用未被弃用
|
||||
got, err := ps2.Call("grantprobe", "probe", map[string]interface{}{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := got.(map[string]interface{})
|
||||
if m == nil || m["error"] != nil {
|
||||
t.Fatalf("授权后同一 worker 调用失败(引用被弃用): %v", got)
|
||||
}
|
||||
if s, _ := m["status"].(float64); int(s) != 200 {
|
||||
t.Fatalf("status=%v, want 200", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostGrantsPersistAcrossRestart 是第二个真实缺陷的回归:host 授权此前只
|
||||
// 写进运行态(extraHosts map),从不落盘 —— 重启后授权消失,插件出站全部被拦,
|
||||
// 而设置页看不出任何异常(actions 有持久化、hosts 没有,读起来像"都持久化了")。
|
||||
func TestHostGrantsPersistAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||||
if err := ps.LoadSource("grantprobe", `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = {}
|
||||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||||
return P
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.GrantExtraHosts("grantprobe", []string{"portal.example.com", "api.partner.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 落盘验证
|
||||
b, err := os.ReadFile(filepath.Join(dir, ".kernel-perms.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("授权未落盘: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(b), "portal.example.com") {
|
||||
t.Fatalf("host 授权应写入 .kernel-perms.json,文件内容: %s", b)
|
||||
}
|
||||
|
||||
// 重启语义:新 registry 读回授权
|
||||
ps2 := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
|
||||
if err := ps2.LoadKernelPermissions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ps2.ExtraHostsGranted()["grantprobe"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("重启后 host 授权应恢复 2 条, 实得 %v", got)
|
||||
}
|
||||
// 插件再加载后,授权可直接使用(GrantExtraHosts 只接受"声明过"的主机,
|
||||
// 所以重载插件不会丢授权这件事本身就是恢复生效的证据)
|
||||
if err := ps2.LoadSource("grantprobe", `
|
||||
local P = {}
|
||||
P.name = "grantprobe"
|
||||
P.version = "1.0"
|
||||
P.exports = {}
|
||||
P.hosts = { "portal.example.com", "api.partner.example" }
|
||||
return P
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = ps2.ExtraHostsGranted()["grantprobe"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("插件重载后授权应保留, 实得 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,6 +105,18 @@ func normalizeHost(h string) string {
|
||||
// rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED extra
|
||||
// host into the allowlist the HTTP bridge consults. Caller holds ps.mu.
|
||||
//
|
||||
// The allowlist object is mutated IN PLACE (allowHTTP.add) rather than replaced:
|
||||
// already-booted plugin workers hold the ORIGINAL pointer in their Lua closures
|
||||
// (bootPluginState captured it at boot), so replacing the field would strand
|
||||
// them on a stale copy — a grant would be recorded and displayed while requests
|
||||
// kept being refused. Mutating the shared object makes every closure see the
|
||||
// new hosts immediately.
|
||||
//
|
||||
// Note this FOLDS sources + grants additively: a host that loses its grant
|
||||
// cannot be un-allowed this way, which is acceptable because grants are
|
||||
// append-only operator approvals (revocation is a restart away — the persisted
|
||||
// file is the authority on boot).
|
||||
//
|
||||
// Declared-but-not-granted hosts are deliberately excluded: that exclusion is the
|
||||
// whole security property, so it is the one thing this function must not be
|
||||
// tempted to "helpfully" relax.
|
||||
@ -113,7 +125,7 @@ func (ps *Plugins) rebuildOutboundAllowlistLocked() {
|
||||
for _, granted := range ps.extraHosts {
|
||||
hosts = append(hosts, granted...)
|
||||
}
|
||||
ps.allowHTTP = newHTTPAllowlist(hosts)
|
||||
ps.allowHTTP.add(hosts)
|
||||
}
|
||||
|
||||
// readPluginExtraHosts reads `plugin.hosts`, the outbound hosts a plugin needs
|
||||
|
||||
@ -641,15 +641,23 @@ func (ps *Plugins) LoadDir() error {
|
||||
// directory does not exist yet, mirroring the adapter seeding rule: once the
|
||||
// directory exists it is authoritative, so deleting or editing a shipped plugin
|
||||
// is a real action that survives restarts.
|
||||
// SeedBundled writes the plugins shipped with the gateway into dir when they are
|
||||
// MISSING.
|
||||
//
|
||||
// This used to be "write everything only when the directory does not exist",
|
||||
// which silently stopped delivering plugins added to the binary LATER: the
|
||||
// directory had existed since the first install, so a newly embedded plugin
|
||||
// (e.g. channels.lua) never landed on an upgraded deployment — it was in the
|
||||
// binary and completely invisible in /api/plugins. The directory's mere
|
||||
// existence is not evidence that its contents are current.
|
||||
//
|
||||
// The rule is now per-file: seed a bundled plugin only when a file of that name
|
||||
// is absent. An OPERATOR'S file always wins, so customising or replacing a
|
||||
// bundled plugin survives an upgrade — the same rule adapters already use.
|
||||
func (ps *Plugins) SeedBundled() error {
|
||||
if ps.dir == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(ps.dir); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return writeBundledPlugins(ps.dir)
|
||||
}
|
||||
|
||||
|
||||
@ -287,6 +287,30 @@ local PLATFORMS = {
|
||||
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
|
||||
base_url = "https://copilot.tencent.com",
|
||||
adapter = "openai",
|
||||
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
|
||||
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
|
||||
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
-- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS);
|
||||
-- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。
|
||||
headers_fn = function(accessToken, cred)
|
||||
local h = {
|
||||
["Authorization"] = "Bearer " .. accessToken,
|
||||
["Accept"] = "application/json",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
local uid = cred and (cred.user_id or cred.uid)
|
||||
if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end
|
||||
local eid = cred and cred.enterprise_id
|
||||
if type(eid) == "string" and eid ~= "" then
|
||||
h["X-Enterprise-Id"] = eid
|
||||
h["X-Tenant-Id"] = eid
|
||||
end
|
||||
return h
|
||||
end,
|
||||
models = {
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
|
||||
"glm-5.3", "glm-5.3-flash", "glm-5.2",
|
||||
@ -803,6 +827,171 @@ local function poolRemove(provider, accountId)
|
||||
poolAccounts(provider)[accountId] = nil
|
||||
end
|
||||
|
||||
-- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径),
|
||||
-- 也可能是**完整凭据 JSON**( buddy / workbuddy 等 state-poll 平台,对齐参考
|
||||
-- buildCredential 的结构:access_token + refresh_token + expires_at + domain + ...)。
|
||||
--
|
||||
-- 本助手统一取出可用的 access token(及 refresh_token),使所有 HTTP 消费点
|
||||
-- (Bearer 头、HMAC 签名)无需关心存的是哪种形态:
|
||||
-- 裸 token → 原样返回
|
||||
-- 完整凭据 JSON 字符串 → 取 .access_token
|
||||
-- 已经是 table → 取 .access_token
|
||||
-- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。
|
||||
local function credentialOf(ref)
|
||||
local raw = ref and __llmsproxy_secrets.get(ref)
|
||||
if not raw or raw == "" then return nil, nil, nil end
|
||||
-- 裸 token 快路:不含 '{' 的字符串直接当 token(绝大多数历史数据)
|
||||
if type(raw) == "string" and raw:sub(1, 1) ~= "{" then return raw, nil, nil end
|
||||
local ok, rec = pcall(json.decode, type(raw) == "string" and raw or "")
|
||||
if ok and type(rec) == "table" then
|
||||
local at = rec.access_token or rec.accessToken or rec.token
|
||||
local rt = rec.refresh_token or rec.refreshToken
|
||||
if at and at ~= "" then return at, rt, rec end
|
||||
-- 是 JSON 但没有 access_token:回退原样(可能是别的形状的凭据)
|
||||
return raw, rt, rec
|
||||
end
|
||||
return raw, nil, nil
|
||||
end
|
||||
|
||||
-- credentialFull 取完整凭据(供需要 refresh_token/expires_at 的路径用)。
|
||||
local function credentialFull(ref)
|
||||
local _, _, full = credentialOf(ref)
|
||||
return full
|
||||
end
|
||||
|
||||
-- jwtClaim 从 JWT 的 payload 读一个声明(不验签,仅用于读取展示/续期元数据)。
|
||||
-- 对齐参考 buddy.ts 的 jwtNickname / jwtExpiresAtMs / jwtSubject:
|
||||
-- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里;
|
||||
-- token 端又只返回 expiresIn(相对秒数)而不含 expiresAt。两处都得从 JWT 兜底。
|
||||
-- 解析失败返回 nil(调用方自行处理)。
|
||||
local function jwtClaim(token, name)
|
||||
if type(token) ~= "string" or token == "" then return nil end
|
||||
local payload = token:match("^[^%.]+%.([^%.]+)")
|
||||
if not payload then return nil end
|
||||
local bin, derr = base64_decode(payload) -- 内核新增:base64url 兼容
|
||||
if not bin or derr then return nil end
|
||||
local ok, rec = pcall(json.decode, bin)
|
||||
if not ok or type(rec) ~= "table" then return nil end
|
||||
return rec[name]
|
||||
end
|
||||
|
||||
-- stripControl 去掉控制字符(含 CR/LF/Tab)并把连续空白折叠为单个空格。
|
||||
-- 对齐参考 stripControlChars:CodeBuddy 的 scope 字段有时返回多行文本,
|
||||
-- 原样落盘会在序列化时被当作多行标量,破坏后续 JSON 解析。
|
||||
local function stripControl(v)
|
||||
if type(v) ~= "string" then return v end
|
||||
v = v:gsub("[%z\1-\31\127]+", " ")
|
||||
v = v:gsub("%s%s+", " ")
|
||||
return (v:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
-- readStr 从响应表安全读字符串字段(兼容后端把数字返为数字)。
|
||||
local function readStr(rec, key)
|
||||
if type(rec) ~= "table" then return "" end
|
||||
local v = rec[key]
|
||||
if type(v) == "string" then return stripControl(v) end
|
||||
if type(v) == "number" then return tostring(v) end
|
||||
return ""
|
||||
end
|
||||
|
||||
-- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。
|
||||
-- 对齐参考 absoluteExpiryMs:
|
||||
-- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串
|
||||
-- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底
|
||||
-- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底)
|
||||
-- 实测:CodeBuddy 的 /v2/plugin/auth/token 只返回 expiresIn,没有 expiresAt;
|
||||
-- 不换算的话凭据的 expires_at 永远是空串,UI 一直显示"有效期未知"。
|
||||
local function absExpiryMs(rec, absKey, relKey, accessToken)
|
||||
local absolute = readStr(rec, absKey)
|
||||
if absolute ~= "" then
|
||||
if absolute:match("^%d+$") then
|
||||
local n = tonumber(absolute)
|
||||
if n then
|
||||
return tostring(n > 1e12 and n or n * 1000)
|
||||
end
|
||||
end
|
||||
return absolute
|
||||
end
|
||||
local rel = rec[relKey]
|
||||
local relSec = nil
|
||||
if type(rel) == "number" then relSec = rel
|
||||
elseif type(rel) == "string" and rel:match("^%-?%d+%.?%d*$") then relSec = tonumber(rel) end
|
||||
if relSec == nil then return "" end
|
||||
-- 基准:access_token 的 iat(签发时刻)优先,缺失时用当前时刻。
|
||||
local iat = jwtClaim(accessToken, "iat")
|
||||
local baseMs = (type(iat) == "number" and iat * 1000) or (os.time and os.time() * 1000 or 0)
|
||||
return tostring(baseMs + relSec * 1000)
|
||||
end
|
||||
|
||||
-- buildCredential 把 token 响应 + account 响应组合为可持久化的完整凭据 JSON。
|
||||
-- 逐字段对齐参考 buddy.ts 的 buildCredential / parseTokenData / parseAccountData:
|
||||
-- access_token / refresh_token / expires_at / refresh_expires_at /
|
||||
-- token_type / scope / domain / user_id / nickname / enterprise_id / account_type
|
||||
-- 昵称回填顺序:account.nickname → JWT.nickname → JWT.preferred_username。
|
||||
-- expires_at 同理:token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。
|
||||
local function buildCredential(data, accountData)
|
||||
local access = readStr(data, "accessToken")
|
||||
if access == "" then access = readStr(data, "access_token") end
|
||||
local refresh = readStr(data, "refreshToken")
|
||||
if refresh == "" then refresh = readStr(data, "refresh_token") end
|
||||
local tokenType = readStr(data, "tokenType")
|
||||
if tokenType == "" then tokenType = "Bearer" end
|
||||
local acct = type(accountData) == "table" and accountData or {}
|
||||
local nickname = readStr(acct, "nickname")
|
||||
if nickname == "" then nickname = readStr(acct, "nickName") end
|
||||
if nickname == "" then
|
||||
local jn = jwtClaim(access, "nickname")
|
||||
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "preferred_username") end
|
||||
if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "name") end
|
||||
if type(jn) == "string" then nickname = stripControl(jn) end
|
||||
end
|
||||
local uid = readStr(acct, "uid")
|
||||
if uid == "" then
|
||||
local sub = jwtClaim(access, "sub")
|
||||
if type(sub) == "string" then uid = sub end
|
||||
end
|
||||
local acctType = readStr(acct, "type")
|
||||
if acctType == "" then acctType = "personal" end
|
||||
return {
|
||||
access_token = access,
|
||||
refresh_token = refresh,
|
||||
expires_at = absExpiryMs(data, "expiresAt", "expiresIn", access),
|
||||
refresh_expires_at = absExpiryMs(data, "refreshExpiresAt", "refreshExpiresIn", access),
|
||||
token_type = tokenType,
|
||||
scope = readStr(data, "scope"),
|
||||
domain = readStr(data, "domain"),
|
||||
user_id = uid,
|
||||
nickname = nickname,
|
||||
enterprise_id = readStr(acct, "enterpriseId"),
|
||||
account_type = acctType,
|
||||
}
|
||||
end
|
||||
|
||||
-- credentialExpiresAtMs 从凭据表解析毫秒过期时刻(兼容毫秒/秒级时间戳/ISO)。
|
||||
-- 对齐参考 credentialExpiresAtMs:expires_at 为空时回退解析 access_token 的 JWT exp。
|
||||
local function credentialExpiresAtMs(cred)
|
||||
if type(cred) ~= "table" then return nil end
|
||||
local raw = cred.expires_at
|
||||
if type(raw) == "string" and raw ~= "" then
|
||||
if raw:match("^%d+$") then
|
||||
local n = tonumber(raw)
|
||||
if n then return n > 1e12 and n or n * 1000 end
|
||||
end
|
||||
-- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss" 形态(无时区偏移时按 UTC)
|
||||
local Y, Mo, D, H, Mi, S = raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)")
|
||||
if Y then return os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D),
|
||||
hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) * 1000 end
|
||||
end
|
||||
local exp = jwtClaim(cred.access_token, "exp")
|
||||
if type(exp) == "number" then return exp * 1000 end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- credentialIsRefreshable 凭据是否携带可静默续期的 refresh_token。
|
||||
local function credentialIsRefreshable(cred)
|
||||
return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= ""
|
||||
end
|
||||
|
||||
-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true }
|
||||
local function disabledModelsOf(provider)
|
||||
if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end
|
||||
@ -840,7 +1029,8 @@ local function syncAccountSource(provider, accountId)
|
||||
local plat = platformById(provider)
|
||||
local acct = poolGet(provider, accountId)
|
||||
if not plat or not acct then return end
|
||||
local tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
-- 凭据可能是裸 token 或完整凭据 JSON;上游源只吃 access token。
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if not tok or tok == "" then return end
|
||||
local name = accountSourceName(provider, accountId)
|
||||
local models = {}
|
||||
@ -1102,6 +1292,14 @@ end
|
||||
-- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep):
|
||||
-- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死
|
||||
-- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。
|
||||
--
|
||||
-- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷):
|
||||
-- 页面流程是「先 account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
|
||||
-- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走
|
||||
-- 兼容旧路径,凭据落到 `channels:<plat>` 这个**与占位账号无关的键**,而账号池
|
||||
-- 条目的 credentialRef 指向 `channels:<plat>:<aid>` —— 于是 account/list 报
|
||||
-- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法
|
||||
-- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。
|
||||
function plugin.poll_login(req)
|
||||
local id = (req and req.query and req.query.id) or ""
|
||||
sweepSessions()
|
||||
@ -1119,7 +1317,10 @@ function plugin.poll_login(req)
|
||||
s.state = "failed"
|
||||
s.message = rerr
|
||||
else
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account })
|
||||
-- ⚠️ 必须带 account_id:页面流程先 account/add 建了占位账号,
|
||||
-- 不带就会落到与占位账号无关的 `channels:<plat>` 键(见 poll_login 顶部注释)。
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = cred,
|
||||
account = acct or payload.account, account_id = s.account_id })
|
||||
if res.json and res.json.ok then
|
||||
s.state = "completed"
|
||||
s.account = acct or payload.account
|
||||
@ -1149,21 +1350,36 @@ function plugin.poll_login(req)
|
||||
local code = rec.code
|
||||
local data = rec.data
|
||||
if resp.status == 200 and data ~= nil and type(data) == "table" and (data.accessToken or data.access_token) then
|
||||
-- token 就绪。再拿账号(失败不阻塞,回退默认名)
|
||||
local tok = data.accessToken or data.access_token
|
||||
local acct = "codebuddy"
|
||||
-- token 就绪。再拿账号(失败不阻塞,回退默认名)。
|
||||
local access = data.accessToken or data.access_token
|
||||
local accountData = nil
|
||||
local aresp, aerr = __llmsproxy_http({
|
||||
url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token),
|
||||
method = "GET",
|
||||
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. tok },
|
||||
headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. access },
|
||||
})
|
||||
if not aerr and aresp and aresp.status == 200 then
|
||||
local aok, arec = pcall(json.decode, aresp.body or "")
|
||||
if aok and type(arec) == "table" and type(arec.data) == "table" then
|
||||
acct = tostring(arec.data.nickName or arec.data.nickname or arec.data.username or acct)
|
||||
accountData = arec.data
|
||||
end
|
||||
end
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = tok, account = acct })
|
||||
-- ① 组合完整凭据 JSON(对齐参考 buildCredential):带 refresh_token /
|
||||
-- expires_at / domain / user_id / nickname,而非只存裸 access_token。
|
||||
-- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默
|
||||
-- 续期,且 expires_at 未知。
|
||||
local credTable = buildCredential(data, accountData)
|
||||
local credJson = json.encode(credTable)
|
||||
local acct = credTable.nickname
|
||||
if acct == nil or acct == "" then
|
||||
acct = credTable.user_id
|
||||
end
|
||||
if acct == nil or acct == "" then acct = "codebuddy" end
|
||||
-- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的
|
||||
-- `channels:<plat>` 键,账号池查不到。
|
||||
local res = plugin.complete_login({ platform = s.platform, credential = credJson,
|
||||
account = acct, account_id = s.account_id,
|
||||
expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) })
|
||||
if res.json and res.json.ok then
|
||||
s.state = "completed"
|
||||
s.account = acct
|
||||
@ -1209,9 +1425,27 @@ function plugin.complete_login(req)
|
||||
local ref = acct.credentialRef
|
||||
local _, serr = __llmsproxy_secrets.set(ref, token)
|
||||
if serr then return { status = 400, json = { error = serr } } end
|
||||
acct.nickname = args.account or acct.nickname or aid
|
||||
acct.expiresAt = args.expires_at or acct.expiresAt
|
||||
acct.refreshable = args.refreshable or acct.refreshable or false
|
||||
-- 从凭据自动补齐展示/续期字段(调用方漏传也不丢)。
|
||||
-- 对齐参考 saveCredential:昵称取 credential.nickname,
|
||||
-- expiresAt 由 credentialExpiresAtMs 推断(expires_at 或 JWT exp),
|
||||
-- refreshable 看是否携带 refresh_token。
|
||||
local credTbl = nil
|
||||
if type(token) == "string" and token:sub(1, 1) == "{" then
|
||||
local dok, drec = pcall(json.decode, token)
|
||||
if dok and type(drec) == "table" then credTbl = drec end
|
||||
end
|
||||
local derivedNick = credTbl and credTbl.nickname
|
||||
if derivedNick == nil or derivedNick == "" then derivedNick = credTbl and credTbl.user_id end
|
||||
local derivedExp = credTbl and credentialExpiresAtMs(credTbl)
|
||||
acct.nickname = args.account or derivedNick or acct.nickname or aid
|
||||
acct.expiresAt = args.expires_at or (derivedExp and tostring(derivedExp)) or acct.expiresAt
|
||||
if args.refreshable ~= nil then
|
||||
acct.refreshable = args.refreshable and true or false
|
||||
elseif credTbl ~= nil then
|
||||
acct.refreshable = credentialIsRefreshable(credTbl)
|
||||
else
|
||||
acct.refreshable = acct.refreshable or false
|
||||
end
|
||||
poolAdd(plat.id, acct)
|
||||
-- 终结该账号的未决会话
|
||||
for id, sess in pairs(sessions()) do
|
||||
@ -1228,6 +1462,9 @@ function plugin.complete_login(req)
|
||||
-- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。
|
||||
local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token)
|
||||
if err then return { status = 400, json = { error = err } } end
|
||||
-- 上游源只吃 access token:凭据可能是完整 JSON(buddy 等 state-poll 平台),
|
||||
-- 必须经 credentialOf 取出 access_token,不能把整个 JSON 当 api_key 发上去。
|
||||
local legacyTok = credentialOf("channels:" .. plat.id) or token
|
||||
|
||||
plugin.state.accounts[plat.id] = {
|
||||
account = args.account or "已登录",
|
||||
@ -1246,7 +1483,7 @@ function plugin.complete_login(req)
|
||||
name = "channels-" .. plat.id,
|
||||
base_url = plat.base_url,
|
||||
adapter = "openai",
|
||||
api_key = token,
|
||||
api_key = legacyTok,
|
||||
max_concurrent = 2,
|
||||
models = (function()
|
||||
local ms = {}
|
||||
@ -1286,9 +1523,9 @@ function plugin.claim_daily(req)
|
||||
if args.account_id and args.account_id ~= "" then
|
||||
acctEntry = poolGet(plat.id, args.account_id)
|
||||
if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end
|
||||
tok = __llmsproxy_secrets.get(acctEntry.credentialRef)
|
||||
tok = credentialOf(acctEntry.credentialRef)
|
||||
else
|
||||
tok = __llmsproxy_secrets.get("channels:" .. plat.id)
|
||||
tok = credentialOf("channels:" .. plat.id)
|
||||
end
|
||||
if not tok then return { status = 400, json = { error = "未登录" } } end
|
||||
|
||||
@ -1357,7 +1594,7 @@ function plugin.auto_claim()
|
||||
plugin.claim_daily({ platform = plat.id })
|
||||
end
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
if a.enabled ~= false and __llmsproxy_secrets.get(a.credentialRef) then
|
||||
if a.enabled ~= false and credentialOf(a.credentialRef) then
|
||||
plugin.claim_daily({ platform = plat.id, account_id = a.id })
|
||||
end
|
||||
end
|
||||
@ -1503,7 +1740,7 @@ function plugin.account_list(req)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local out = {}
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
local has = __llmsproxy_secrets.get(a.credentialRef)
|
||||
local has = credentialOf(a.credentialRef)
|
||||
out[#out+1] = {
|
||||
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
|
||||
credentialRef = a.credentialRef, refreshable = a.refreshable or false,
|
||||
@ -1561,7 +1798,7 @@ function plugin.account_test(req)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local acct = poolGet(plat.id, args and args.account_id)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||||
local tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end
|
||||
-- 同步一次源(确保最新凭据已入),返回同步结果
|
||||
syncAccountSource(plat.id, acct.id)
|
||||
@ -1582,7 +1819,7 @@ function plugin.account_refresh(req)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
local acct = poolGet(plat.id, args and args.account_id)
|
||||
if not acct then return { status = 400, json = { error = "账号不存在" } } end
|
||||
local tok = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end
|
||||
return { status = 200, json = { ok = true } }
|
||||
end
|
||||
@ -1661,7 +1898,7 @@ function plugin.provider_status(req)
|
||||
for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end
|
||||
local closed = (p.models and #p.models > 0) and disabledN == #p.models
|
||||
local state_accounts = plugin.state.accounts[p.id]
|
||||
local legacyN = (state_accounts and __llmsproxy_secrets.get("channels:" .. p.id)) and 1 or 0
|
||||
local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0
|
||||
out[#out+1] = {
|
||||
id = p.id, name = p.name, flow = p.flow, closed = closed,
|
||||
models = { total = #(p.models or {}), disabled = disabledN },
|
||||
@ -1684,10 +1921,11 @@ function plugin.provider_set_enabled(req)
|
||||
for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end
|
||||
-- 旧路径也启用
|
||||
local legacy = plugin.state.accounts[plat.id]
|
||||
if legacy and __llmsproxy_secrets.get("channels:" .. plat.id) then
|
||||
local legacyTok = credentialOf("channels:" .. plat.id)
|
||||
if legacy and legacyTok then
|
||||
__llmsproxy_kernel.call("add_source", { source = {
|
||||
name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai",
|
||||
api_key = __llmsproxy_secrets.get("channels:" .. plat.id), max_concurrent = 2,
|
||||
api_key = legacyTok, max_concurrent = 2,
|
||||
models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(),
|
||||
} })
|
||||
end
|
||||
@ -1744,11 +1982,11 @@ function plugin.backup_export(req)
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
for _, a in ipairs(poolList(p.id)) do
|
||||
payload.accounts[#payload.accounts+1] = a
|
||||
local tok = __llmsproxy_secrets.get(a.credentialRef)
|
||||
local tok = credentialOf(a.credentialRef)
|
||||
if tok then payload.credentials[a.credentialRef] = tok end
|
||||
end
|
||||
-- 旧路径单账号也备份
|
||||
local lt = __llmsproxy_secrets.get("channels:" .. p.id)
|
||||
local lt = credentialOf("channels:" .. p.id)
|
||||
if lt then payload.credentials["channels:" .. p.id] = lt end
|
||||
end
|
||||
return { status = 200, json = { ok = true, payload = payload } }
|
||||
@ -1813,8 +2051,18 @@ end
|
||||
-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances)
|
||||
-- ============================================================================
|
||||
-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。
|
||||
local function fetchAccountJSON(spec, tok)
|
||||
local headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
|
||||
local function fetchAccountJSON(spec, tok, credTable, plat)
|
||||
local headers
|
||||
-- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。
|
||||
-- buddy 在平台层声明一次,签到与余额复用同一套身份头。
|
||||
local hfn = spec.headers_fn or (plat and plat.headers_fn)
|
||||
if hfn then
|
||||
local okf, h = pcall(hfn, tok, credTable or {})
|
||||
if okf and type(h) == "table" then headers = h end
|
||||
end
|
||||
if not headers then
|
||||
headers = spec.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = spec.url, method = spec.method or "GET",
|
||||
headers = headers, body = spec.body,
|
||||
@ -1838,9 +2086,9 @@ function plugin.credits_status(req)
|
||||
local status = nil
|
||||
local cs = plat.checkin_status
|
||||
if cs then
|
||||
local tok = __llmsproxy_secrets.get(a.credentialRef)
|
||||
local tok = credentialOf(a.credentialRef)
|
||||
if tok then
|
||||
local rec, err = fetchAccountJSON(cs, tok)
|
||||
local rec, err = fetchAccountJSON(cs, tok, credentialFull(a.credentialRef), plat)
|
||||
if rec and plat.checkin_status.status_fn then
|
||||
local okf, st = pcall(plat.checkin_status.status_fn, rec)
|
||||
if okf then status = st end
|
||||
@ -1870,11 +2118,11 @@ function plugin.credits_balances(req)
|
||||
if not b then
|
||||
entry.error = "平台未声明余额端点"
|
||||
else
|
||||
local tok = __llmsproxy_secrets.get(a.credentialRef)
|
||||
local tok = credentialOf(a.credentialRef)
|
||||
if not tok then
|
||||
entry.error = "无凭据"
|
||||
else
|
||||
local rec, err = fetchAccountJSON(b, tok)
|
||||
local rec, err = fetchAccountJSON(b, tok, credentialFull(a.credentialRef), plat)
|
||||
if err then
|
||||
entry.error = err
|
||||
elseif b.total_fn then
|
||||
@ -2132,7 +2380,7 @@ function plugin.onboarding_claim(req)
|
||||
return { status = 400, json = { error = "该平台无新人任务" } }
|
||||
end
|
||||
-- raccoon 实测端点:POST {apiBase}/login/points/grant
|
||||
local tok = __llmsproxy_secrets.get("channels:" .. plat.id)
|
||||
local tok = credentialOf("channels:" .. plat.id)
|
||||
if not tok then return { status = 400, json = { error = "未登录" } } end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = plat.base_url .. "/login/points/grant", method = "POST",
|
||||
@ -2185,6 +2433,26 @@ function plugin.set_endpoints(req)
|
||||
if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end
|
||||
end
|
||||
end
|
||||
-- checkin_status / balance:声明式端点覆盖。参考把各平台的计费/状态端点当
|
||||
-- 产品配置,运维要能整体改指(自建代理、镜像、测试假上游)。此前只覆盖了
|
||||
-- 旧的 plat.checkin,buddy 这类"声明 checkin_status/balance"的平台无从覆盖 ——
|
||||
-- 自动化测试也就无法把它们的端点指到假上游。
|
||||
if type(args.checkin_status) == "table" and plat.checkin_status then
|
||||
for _, k in ipairs({ "url", "method", "body" }) do
|
||||
if args.checkin_status[k] then
|
||||
plat.checkin_status[k] = tostring(args.checkin_status[k])
|
||||
changed[#changed+1] = "checkin_status." .. k
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(args.balance) == "table" and plat.balance then
|
||||
for _, k in ipairs({ "url", "method", "body" }) do
|
||||
if args.balance[k] then
|
||||
plat.balance[k] = tostring(args.balance[k])
|
||||
changed[#changed+1] = "balance." .. k
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(args.callback) == "table" and plat.callback then
|
||||
for _, k in ipairs({ "exchange_url" }) do
|
||||
if args.callback[k] then plat.callback[k] = tostring(args.callback[k]) changed[#changed+1] = "callback." .. k end
|
||||
@ -2199,7 +2467,7 @@ function plugin.list_platforms(req)
|
||||
local out = {}
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
local acct = accountOf(p.id) or {}
|
||||
local has = __llmsproxy_secrets.get("channels:" .. p.id)
|
||||
local has = credentialOf("channels:" .. p.id)
|
||||
out[#out + 1] = {
|
||||
id = p.id, name = p.name, flow = p.flow,
|
||||
logged_in = has ~= nil,
|
||||
|
||||
@ -744,11 +744,21 @@ func writeBundledPlugins(dir string) error {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".lua" {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(dir, e.Name())
|
||||
// Per-file seeding: a plugin already present is left ALONE. That is what
|
||||
// makes an operator's own copy (or a locally edited one) survive an
|
||||
// upgrade, and it is also why a newly embedded plugin still arrives —
|
||||
// absence of THAT file, not of the directory, is the trigger.
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, e.Name()), data, 0644); err != nil {
|
||||
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -1348,6 +1358,38 @@ func setupGlobals(L *golua.State) {
|
||||
L.PushString(base64.StdEncoding.EncodeToString([]byte(L.ToString(1))))
|
||||
return 1
|
||||
})
|
||||
// base64_decode accepts standard, URL-safe and unpadded alphabets. It exists
|
||||
// because a plugin reading a JWT payload (e.g. to read `exp`/`nickname` when
|
||||
// the token endpoint omits them) must decode base64url, and hardcoding that
|
||||
// in Lua would mean reimplementing base64 in a plugin. Generic, no vendor
|
||||
// knowledge: an invalid string yields (nil, err), never a partial result.
|
||||
registerFn(L, "base64_decode", func(L *golua.State) int {
|
||||
s := strings.TrimSpace(L.ToString(1))
|
||||
if s == "" {
|
||||
L.PushString("")
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
// Normalise URL-safe alphabet and re-pad to a multiple of 4.
|
||||
s = strings.NewReplacer("-", "+", "_", "/").Replace(s)
|
||||
if pad := len(s) % 4; pad != 0 {
|
||||
if pad == 1 {
|
||||
L.PushNil()
|
||||
L.PushString("invalid base64 length")
|
||||
return 2
|
||||
}
|
||||
s += strings.Repeat("=", 4-pad)
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
L.PushString(string(b))
|
||||
L.PushNil()
|
||||
return 2
|
||||
})
|
||||
registerFn(L, "tohex", func(L *golua.State) int {
|
||||
L.PushString(hex.EncodeToString([]byte(L.ToString(1))))
|
||||
return 1
|
||||
|
||||
@ -1868,3 +1868,97 @@ func TestAnthropicUsageCacheMapping(t *testing.T) {
|
||||
t.Errorf("usage-less chunk must not emit a usage object: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedBundledFillsMissingFilesInExistingDir 是真实事故的回归测试。
|
||||
//
|
||||
// 事故:SeedBundled 原本"目录已存在就什么都不做"。生产 plugin_dir 从首次安装
|
||||
// 起就存在,于是后来加入二进制的新内置插件(channels.lua)永远不会落盘 ——
|
||||
// 它在 go:embed 里、却完全不出现在 /api/plugins(线上只加载 billing/quota/usage)。
|
||||
//
|
||||
// 本测试构造那个确切条件:目录已存在且只装了部分内置插件,断言缺失的被补齐、
|
||||
// 已存在的(模拟运维自定义)**不被覆盖**。
|
||||
func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "plugins")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟:目录已存在,里面只有运维自己的一份 billing.lua(内容故意不同)
|
||||
const customBilling = "-- operator's own billing plugin\nreturn { name='billing', version='9.9.9' }"
|
||||
if err := os.WriteFile(filepath.Join(dir, "billing.lua"), []byte(customBilling), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
ps := NewPlugins(vm, dir)
|
||||
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// ① 已存在的运维文件不被覆盖
|
||||
got, err := os.ReadFile(filepath.Join(dir, "billing.lua"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != customBilling {
|
||||
t.Errorf("运维自己的 billing.lua 被覆盖了 —— 升级会抹掉运维的改动")
|
||||
}
|
||||
|
||||
// ② 每个内置插件现在都在目录里(这正是 channels.lua 缺失的那个洞)
|
||||
entries, err := bundledAdapters.ReadDir(bundledPluginsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".lua" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, e.Name())); err != nil {
|
||||
t.Errorf("内置插件 %s 未被补齐(目录已存在时被跳过)", e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 补齐后确实能被加载(channels 必须真的出现在插件表里)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range ps.List() {
|
||||
names[p["name"].(string)] = true
|
||||
}
|
||||
if !names["channels"] {
|
||||
t.Errorf("channels 插件应被 seed 并加载, 实得 %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedBundledIdempotent 二次调用不应改变已就位的文件。
|
||||
func TestSeedBundledIdempotent(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "plugins")
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
ps := NewPlugins(vm, dir)
|
||||
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := os.ReadFile(filepath.Join(dir, "channels.lua"))
|
||||
// 运维改一笔
|
||||
edited := append(before, []byte("\n-- operator edit\n")...)
|
||||
if err := os.WriteFile(filepath.Join(dir, "channels.lua"), edited, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.SeedBundled(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(dir, "channels.lua"))
|
||||
if string(after) != string(edited) {
|
||||
t.Error("二次 seed 覆盖了已存在的文件")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user