mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69da3c9b92 | |||
| 1be34557dd |
82
internal/core/channels_orphan_callback_test.go
Normal file
82
internal/core/channels_orphan_callback_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
|
||||
//
|
||||
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
|
||||
// (对应的 .json 早已消失)。它们永远不会被清,因为:
|
||||
//
|
||||
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
|
||||
// · 进程被强杀时 on_unload 没机会跑;
|
||||
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
|
||||
//
|
||||
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
|
||||
// 而不是"某函数被调用过"。
|
||||
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
|
||||
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
|
||||
orphans := []string{
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
|
||||
keep := filepath.Join(pluginDir, ".channels-store.json")
|
||||
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 加载插件 = 走真实启动路径
|
||||
c := kernelTestCore(t)
|
||||
defer c.Close()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
|
||||
for i := 0; i < 50; i++ {
|
||||
left := 0
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
left++
|
||||
}
|
||||
}
|
||||
if left == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
|
||||
"(真实事故:生产攒了 18 个)", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
|
||||
}
|
||||
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
|
||||
}
|
||||
|
||||
var _ = lua.Plugins{}
|
||||
169
internal/core/channels_trae_pkce_test.go
Normal file
169
internal/core/channels_trae_pkce_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
|
||||
// 而不是去调 ExchangeToken 换一个不存在的码。
|
||||
//
|
||||
// 真实缺陷(用户报「登录后显示失败」):
|
||||
// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进
|
||||
// exchange_body 再打 ExchangeToken。但:
|
||||
//
|
||||
// · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
|
||||
// · client_id 当时是空串。
|
||||
//
|
||||
// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」,
|
||||
// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。
|
||||
//
|
||||
// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调
|
||||
// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
|
||||
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
|
||||
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
|
||||
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
|
||||
//
|
||||
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
|
||||
// 故判据直接摆好那个文件再调 poll_login。
|
||||
status, body := h.beginAndPollWithCallback(map[string]string{
|
||||
"code": "PKCE_AUTH_CODE_XYZ",
|
||||
})
|
||||
|
||||
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
|
||||
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
|
||||
// 前者写成了后者,于是把正确的实现判成错。
|
||||
if !strings.Contains(body, "state:failed") {
|
||||
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
|
||||
status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
|
||||
"实际打了 %d 次", n)
|
||||
}
|
||||
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
|
||||
blob := body
|
||||
if !strings.Contains(blob, "PKCE") {
|
||||
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
|
||||
}
|
||||
if strings.Contains(blob, "授权码交换失败") {
|
||||
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
|
||||
}
|
||||
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
|
||||
}
|
||||
|
||||
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
|
||||
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
|
||||
func TestTraeExchangeSendsRealClientID(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
|
||||
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
|
||||
status, body := h.beginAndCallback(map[string]string{
|
||||
"refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
|
||||
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
|
||||
}
|
||||
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
|
||||
}
|
||||
|
||||
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
|
||||
func TestTraeClientIDIsNotBlank(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
|
||||
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
|
||||
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
|
||||
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
|
||||
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
|
||||
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
|
||||
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
|
||||
"必然 400 且把真因掩盖成交换失败")
|
||||
}
|
||||
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
|
||||
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
|
||||
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
|
||||
}
|
||||
// ③ PKCE 判定必须仍在 traeCallback 内
|
||||
if !strings.Contains(src, "本实现暂不支持该流程") {
|
||||
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
|
||||
}
|
||||
}
|
||||
|
||||
// beginAndPollWithCallback 走**真实登录用的那条路径**:
|
||||
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
|
||||
//
|
||||
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
|
||||
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
|
||||
// trae 分支里。
|
||||
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
|
||||
h.t.Helper()
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
|
||||
}
|
||||
sess := j["session"].(map[string]interface{})
|
||||
sid, _ := sess["id"].(string)
|
||||
nonce, _ := sess["nonce"].(string)
|
||||
if sid == "" {
|
||||
h.t.Fatalf("会话缺 id: %#v", sess)
|
||||
}
|
||||
|
||||
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
|
||||
rec := map[string]string{"state": nonce}
|
||||
for k, v := range cbQuery {
|
||||
rec[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(rec)
|
||||
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
|
||||
if err := os.WriteFile(cbPath, b, 0600); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
|
||||
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
|
||||
"session_id": sid, "file": cbPath,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("设置 loopback 文件: %v", err)
|
||||
}
|
||||
|
||||
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
77
internal/lua/plugin_unload_on_close_test.go
Normal file
77
internal/lua/plugin_unload_on_close_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 关闭 Plugins 时必须给插件一次 on_unload 机会。
|
||||
//
|
||||
// 真实缺陷:Close() 此前只关 shard 与 saver,**从不调用 on_unload** —— 而
|
||||
// on_unload 只在单插件 Unload 时调。于是「进程关闭/测试结束」这条路径上,
|
||||
// 起了 OS 进程的插件(回环回调接收器、浏览器栈)永远收不了尾,留下孤儿进程。
|
||||
// 实测本机积累到 995 个 python 监听器(828 个超 24 小时),全是测试残留。
|
||||
//
|
||||
// 这个判据看的是**真实外部效应**(插件在 on_unload 里改磁盘上的文件),
|
||||
// 而不是「钩子被调用过」这类内部信号 —— 后者在钩子写错名字时也会假绿。
|
||||
func TestPluginsCloseCallsOnUnload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "unloaded.marker")
|
||||
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "leaky", version = "1.0.0", exports = {} }
|
||||
plugin.ui = nil
|
||||
function plugin.on_unload()
|
||||
local f = io.open("` + marker + `", "w")
|
||||
if f then f:write("reaped"); f:close() end
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "leaky.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
// 关之前不该有标记
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("on_unload 在 Close 之前就被调了?")
|
||||
}
|
||||
ps.Close()
|
||||
|
||||
// ★ 真判据:文件必须被写出来(= on_unload 真的跑过)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("Close() 没有调用 on_unload —— 起了 OS 进程的插件会留下孤儿进程"+
|
||||
"(真实缺陷:995 个 python 监听器残留)。marker 不存在: %v", err)
|
||||
}
|
||||
t.Log("✓ Close() 触发了 on_unload(孤儿进程不会再堆积)")
|
||||
}
|
||||
|
||||
// on_unload 抛错不得让 Close 崩掉(best-effort 契约)。
|
||||
func TestPluginsCloseSurvivesFailingOnUnload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "boom", version = "1.0.0", exports = {} }
|
||||
function plugin.on_unload() error("故意炸") end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "boom.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
ps.Close() // 不得 panic
|
||||
}
|
||||
@ -572,10 +572,72 @@ func (ps *Plugins) Close() {
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
// ★ 顺序至关重要:**先落盘,再给插件收尾**。
|
||||
//
|
||||
// saver 的最终写入要读每个插件的 Lua 表;on_unload 之后 state 已关,
|
||||
// 读到的就是空 —— 最后一个区间的累积静默丢失(persist_test 的
|
||||
// TestCloseFlushesTail 正是为守住这一点而存在)。
|
||||
//
|
||||
// 所以不能把 callOnUnloadAll 挪到前面,也不能把 save.Close 挪到后面。
|
||||
// Shard VMs are separate golua states; leaving them open leaks a VM per
|
||||
// shard on every reload.
|
||||
ps.shards.Shutdown()
|
||||
ps.save.Close()
|
||||
ps.callOnUnloadAll()
|
||||
}
|
||||
|
||||
// callOnUnloadAll 依次给每个插件调 on_unload(best-effort)。
|
||||
//
|
||||
// 与 Unload 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了
|
||||
// 一个没修 —— 这正是本次缺陷的形状(Unload 有、Close 没有)。
|
||||
func (ps *Plugins) callOnUnloadAll() {
|
||||
ps.mu.Lock()
|
||||
plugins := make([]*Plugin, len(ps.plugins))
|
||||
copy(plugins, ps.plugins)
|
||||
ps.mu.Unlock()
|
||||
for _, p := range plugins {
|
||||
ps.callOnUnload(p)
|
||||
}
|
||||
}
|
||||
|
||||
// callOnUnload 调单个插件的 on_unload(存在才调),并关掉它的 Lua state。
|
||||
//
|
||||
// 为什么关 state 也在这里:on_unload 之后 state 就没用了,而 state 一旦关掉,
|
||||
// 钩子再也调不了 —— 调用与关闭必须紧邻,否则又会漏。Unload 与 Close 各自持有
|
||||
// 的 state 生命周期不同(Unload 还要 rebuild,Close 之后整体销毁),所以这里
|
||||
// 只做「调用 + 置空」,由调用方决定其余步骤。
|
||||
func (ps *Plugins) callOnUnload(p *Plugin) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if err := ps.invokeOnUnload(p, p.Info.Name); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", p.Info.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// invokeOnUnload 真正调钩子。抽出来是为了让 Unload 与 Close 共用一份实现
|
||||
// (含 state 判空与错误返回语义),避免两处契约不一致。
|
||||
func (ps *Plugins) invokeOnUnload(p *Plugin, name string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state == nil || p.state.L == nil {
|
||||
return nil
|
||||
}
|
||||
L := p.state.L
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.Pop(1)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "on_unload")
|
||||
var err error
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
err = callFnOn(L, "on_unload")
|
||||
}
|
||||
L.Pop(2) // plugin table + field remnant
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadDir loads every .lua file in dir as a plugin. Files are loaded in
|
||||
@ -1941,24 +2003,10 @@ func (ps *Plugins) Unload(name string) error {
|
||||
// only guaranteed point where it still can. Best-effort by design — a
|
||||
// hook that errors must not block an unload the operator explicitly asked
|
||||
// for (a wedged plugin has its own path above).
|
||||
p.mu.Lock()
|
||||
if p.state != nil && p.state.L != nil {
|
||||
p.state.L.GetGlobal(pluginGlobal)
|
||||
if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE {
|
||||
L.GetField(-1, "on_unload")
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
if err := callFnOn(L, "on_unload"); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", name, err)
|
||||
}
|
||||
}
|
||||
L.Pop(2) // adapter/plugin table + field remnant
|
||||
} else {
|
||||
L.Pop(1)
|
||||
}
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
//
|
||||
// 与 Close 共用 invokeOnUnload:两处各写一遍必然漂移 —— 本次缺陷正是
|
||||
// 「Unload 有、Close 没有」。
|
||||
_ = ps.invokeOnUnload(p, name)
|
||||
ps.rebuild()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "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_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", "__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_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_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", "__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" }
|
||||
|
||||
|
||||
|
||||
@ -194,7 +194,9 @@ local PLATFORMS = {
|
||||
mode = "trae",
|
||||
exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||||
exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}',
|
||||
client_id = "", -- 参考用 product.clientId;TRAE 回调直传形态不强制,留空
|
||||
client_id = "en1oxy7wnw8j9n", -- 实测值(对齐参考 trae-product.ts 的 clientId)
|
||||
-- ↑ 这个值**不是**可选的:ExchangeToken 的请求体里 ClientID 是必填,
|
||||
-- 留空会让交换稳定 400(详见文件内 traeCallback 的注释)。
|
||||
},
|
||||
},
|
||||
|
||||
@ -2620,6 +2622,23 @@ plugin.state.log = plugin.state.log or {}
|
||||
-- 字段,先填再被 or {} 覆盖就白填了)。
|
||||
loadState()
|
||||
|
||||
-- ★ 启动清扫:删掉上一进程留下的孤儿回调文件。
|
||||
--
|
||||
-- 为什么需要它:loopbacks 追踪表只活在内存里(plugin.state.loopbacks),
|
||||
-- 进程退出后无从得知上一轮起过哪些接收器。于是进程被强杀/SIGKILL 时,
|
||||
-- 那句 on_unload 没机会跑,磁盘上就永久留下 callback-*.port / .json。
|
||||
-- 实测生产攒了 18 个孤儿 .port(对应的 .json 早已消失),且**永远不会被清** ——
|
||||
-- 重启后内存表是空的,reapLoopback 根本不知道它们存在。
|
||||
--
|
||||
-- 判据不是"文件被删了"这么弱,而是"启动后目录里不再有上一轮的残留":
|
||||
-- internal/core/channels_orphan_callback_test.go。
|
||||
do
|
||||
local dir = storeDir()
|
||||
local sh = io.popen and io.popen("rm -f " .. dir ..
|
||||
"/callback-*.port " .. dir .. "/callback-*.json 2>/dev/null")
|
||||
if sh then sh:close() end
|
||||
end
|
||||
|
||||
local function logEvent(msg)
|
||||
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
|
||||
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
|
||||
@ -4843,25 +4862,19 @@ function plugin.poll_login(req)
|
||||
elseif s.platform == "trae" then
|
||||
-- Trae:回调把 token 直接放在 query 里(refreshToken/userJwt/userInfo),
|
||||
-- 故把整个回调 query 交给 traeCallback(它在两条路径上共用)。
|
||||
--
|
||||
-- ★ 这里曾经有一段**自己发明的**「授权码交换」:回调只带 code 时,拿
|
||||
-- exchange_body 去 ExchangeToken。那是错的,而且是稳定 400:
|
||||
-- · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
-- · exchange_body 里根本没有 {code} 占位符,替换是空操作。
|
||||
-- 结果是日志回一句误导性的「授权码交换失败: 400」,把真因
|
||||
-- 「上游走了 PKCE 流程」掩盖成「交换出错了」。
|
||||
--
|
||||
-- 按参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法:
|
||||
-- 带 code 的回调是**合法回调**,只是我们不支持的 PKCE 分支,
|
||||
-- 要**明确报出来**而不是尝试一个不存在的交换。
|
||||
-- traeCallback 已内建这个判定(返回「上游返回了 PKCE 授权码…」)。
|
||||
credential, account, cerr = traeCallback(plat, cb, s)
|
||||
if not credential and code ~= "" then
|
||||
-- 回调只给了 code(PKCE 形态)→ 用 exchange 端点的标准交换
|
||||
local body = (plat.callback.exchange_body or "")
|
||||
:gsub("{client_id}", plat.callback.client_id or "")
|
||||
:gsub("{code}", code)
|
||||
:gsub("{port}", tostring(gatewayPort()))
|
||||
local resp, err = __llmsproxy_http({ url = plat.callback.exchange_url,
|
||||
method = "POST", headers = { ["Content-Type"] = "application/json" }, body = body })
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
cerr = "Trae 授权码交换失败: " .. tostring(err or (resp and resp.status))
|
||||
else
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
local access = ok and type(rec) == "table"
|
||||
and (rec.Token or rec.token or rec.AccessToken or rec.access_token)
|
||||
if not access then cerr = "Trae 授权码交换响应缺少 Token"
|
||||
else credential, account, cerr = json.encode({ access_token = access }), "trae", nil end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Gemini 等标准 OAuth:表单交换(必带 client_secret,见 token_body 注释)
|
||||
local body = (plat.callback.token_body or "")
|
||||
@ -6273,6 +6286,8 @@ plugin.http = {
|
||||
description = "把会话的 noVNC 静态资源与 WebSocket 经网关同端口转发(不暴露 VNC 端口)" },
|
||||
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
|
||||
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
|
||||
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
|
||||
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
|
||||
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
|
||||
roles = { "admin" }, description = "回收服务端浏览器栈" },
|
||||
}
|
||||
@ -7499,6 +7514,24 @@ function plugin.stop_browser(req)
|
||||
end
|
||||
|
||||
-- browser_status:当前浏览器会话状态(给 UI 显示)。
|
||||
-- reap_loopbacks:回收**所有**回环接收器。
|
||||
--
|
||||
-- 为什么需要显式入口:回环本来只在**会话过期**时由 reapLoopback 收掉
|
||||
-- (sweepSessions 是惰性的,靠读取时顺带做)。于是有一条稳定的泄漏路径:
|
||||
-- 测试或运维起了 login 会话后直接退出,会话从未「过期」⇒ 接收器进程常驻。
|
||||
-- 实测本机积累到 995 个(其中 828 个超过 24 小时),全是 /tmp/Test*/ 下的
|
||||
-- 测试残留 —— 生产路径的会被正常回收(因为生产会话真会过期)。
|
||||
--
|
||||
-- 对生产也有用:浏览器/授权页关掉后想立刻清理,不必等会话过期。
|
||||
function plugin.reap_loopbacks(req)
|
||||
local n = 0
|
||||
for sid in pairs(plugin.state.loopbacks or {}) do
|
||||
reapLoopback(sid)
|
||||
n = n + 1
|
||||
end
|
||||
return { status = 200, json = { ok = true, reaped = n } }
|
||||
end
|
||||
|
||||
function plugin.browser_status(req)
|
||||
local b = browserStack()
|
||||
local s = b.session
|
||||
@ -9559,6 +9592,29 @@ function plugin.__store_dir_probe()
|
||||
return { json = { store_dir = storeDir() } }
|
||||
end
|
||||
|
||||
-- plugin.__set_loopback_probe:只给测试用 —— 把某会话的 loopback 回调文件
|
||||
-- 指到测试摆好的路径,以便直接驱动 poll_login 的 callback 分支。
|
||||
--
|
||||
-- 为什么需要它:callback 流的真实登录走 poll_login(读 loopback 文件),
|
||||
-- 而那个文件平时由插件自己起的 python 接收器写入。判据要验的正是**这条路径**
|
||||
-- 的转码/交换行为(真实缺陷曾藏在它的 trae 分支里),但测试不起真实接收器,
|
||||
-- 故需要这个注入点。生产代码不会调用它。
|
||||
function plugin.__set_loopback_probe(args)
|
||||
args = args or {}
|
||||
local sid = tostring(args.session_id or "")
|
||||
local file = tostring(args.file or "")
|
||||
if sid == "" or file == "" then return { json = { error = "need session_id and file" } } end
|
||||
if type(plugin.state.loopbacks) ~= "table" then plugin.state.loopbacks = {} end
|
||||
-- ★ 合并而非覆盖:真实会话的条目里带着 pid/port_file,覆盖掉它们
|
||||
-- 会让 reapLoopback 杀不掉那个 python 接收器 —— 探针自己就成了泄漏源
|
||||
-- (实测:覆盖写法在该测试里留下 1 个孤儿进程,而其他测试已归零)。
|
||||
local cur = plugin.state.loopbacks[sid]
|
||||
if type(cur) ~= "table" then cur = {} end
|
||||
cur.file = file
|
||||
plugin.state.loopbacks[sid] = cur
|
||||
return { json = { ok = true, session_id = sid, file = file, kept_pid = cur.pid or "" } }
|
||||
end
|
||||
|
||||
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
|
||||
-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则
|
||||
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。
|
||||
|
||||
Reference in New Issue
Block a user