feat(channels): Qoder / ZCode / Loomy 从 manual 变为真正可用的自动化登录

审计 12 个平台时发现:四个标 flow="manual" 的平台里,**三个的登录协议完全可以在
Lua 里实现** —— 我把"没实现"写成了"做不到",理由全是错的。

## Qoder:WASM 是聊天加密,不是登录
错误理由:「登录要 WASM 签名头」。实际:WASM 用于 **/algo/ 加密推理端点**,
登录本身是标准 PKCE 设备码轮询(qoder-oauth.ts / qoder.ts):
  授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
           &nonce&machine_id&client_id(本地拼装,不打网络)
  轮询   = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
三个必须照抄的细节(参考点名):
  · client_id 用 **prod 的**(非 prod 会在授权回调阶段被拒)
  · poll 挂 openApiBase(openapi.qoder.sh);authBase 返回 401
  · 404 = 尚无会话(**继续等**,判失败会让登录永远失败)

实现:新增 judge="pending404" 判据 + 既有的 device-poll 机制支持
`GET_QUERY`/`auth_query`(PKCE 授权页形态,无 device_code)。
顺带修 poll_login 的条件(原要求 `s.device_code`,PKCE 形态没有它 → 永远不推进)。

## ZCode:授权流不涉及 captcha
错误理由:「登录要解 captcha」。实际:captcha 属于**另一条已废弃的旁路**
(读本机官方客户端数据);本流注释明确写着「普通进程就能走完」:
  ① POST {origin}/api/v1/oauth/cli/init  Bearer <本地造的 flow_secret>
     → {flow_id, authorize_url, poll_interval_sec}
  ② 用户浏览器打开 authorize_url
  ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
判据(参考点名):4xx(**除 408/429**)才是终态失败;code!=0 或 data 缺失 → pending;
token(zcode JWT) 与 user.user_id 必需,第三方 token 取 bigmodel/zai 二者之一。
另按参考修正 base_url/adapter(走 Anthropic 协议的 zcode.z.ai 免费额度通道)
并补余额端点。

## Loomy:扫码在手机上,协议在 HTTP 里
错误理由:「扫码在微信里完成,Lua 做不了」。实际:扫码动作确实在用户手机上,
但协议全是纯 HTTP:
  ① 取 uuid(授权页 HTML 里抽)② 二维码图 URL(前端渲染)
  ③ 长轮询 long.open.weixin.qq.com/connect/l/qrconnect
  ④ wx_code → 讯飞账号端点换凭据(HMAC-SHA1 签名)
判据(参考点名,早期版本**读反了**导致"扫码后卡在已扫码"):
  408=等待;404=已扫码待确认(**继续轮询**);405=**已确认**(wx_code 在此帧);
  403=取消;402=失效
签名:内核只有 sha256/hmac_sha256,没有 sha1/md5 —— 插件用 openssl 算
(HMAC-SHA1 与 Python 实测一致),**不改内核**。账号头前缀是 `account ak:sig`。

## 顺带修的真 bug
1. `loomyAuthHeaders` 调 `newToken()`,而它定义在文件后段 → Lua 词法作用域下是
   nil,调用即崩。改用内核全局 sha256_hex 造 nonce。
2. `declaredHosts()` 只看 hosts/base_url/checkin → 各 flow 的端点域名
   (open.weixin.qq.com / account.xfyun.cn / openapi.qoder.sh / zcode.z.ai …)
   不在出站白名单里,授权页与长轮询会被拦。已按 flow 补全推导。
3. `set_endpoints` 不支持 wechat —— 自动化测试无法把 Loomy 端点指到假上游
   (端点覆盖是参考明确的产品配置能力,也是测试缝)。已补 wechat/initpoll。
4. 两处 Lua 字符串引号错误(`"$(cat ...)"` 与 `"([^"]+)"` 提前结束字符串,
   插件编译失败)。插件加载测试抓到的。

## 判据
- TestQoderDevicePollBehavior(5 项):授权页含 PKCE 四要素与 prod client_id;
  404 保持 pending;轮询带 verifier/nonce;拿 token 即完成;池路径凭据落账号 ref
- TestZcodeInitPollBehavior(4 项):init 带会话密钥与 provider;pending 保持;
  ready 完成且凭据含 zcode_jwt + 第三方 token;池路径
- TestLoomyWechatPollBehavior(4 项):抽出 uuid 并给二维码;**408/404 都是
  pending**(404 非完成);405 才完成且账号端点用 account ak:sig 签名;403/402 失败
- 全量 core 回归通过;确认测试不写生产插件目录、无孤儿进程
- 内核对照 v2.0.2:diff 为空

## 剩余
Raccoon 仍为 manual(本地承载页 + SMS,含人工输入验证码),另做。
This commit is contained in:
JianFeeeee
2026-10-13 10:02:30 +08:00
parent d86514c474
commit 8949405746
4 changed files with 1101 additions and 27 deletions

View File

@ -0,0 +1,174 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// loomyHarness:Loomy 微信扫码长轮询的测试台。
//
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
//
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
type loomyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
confirmAt int32
gotAuth string
}
func newLoomyHarness(t *testing.T) *loomyHarness {
t.Helper()
h := &loomyHarness{t: t, confirmAt: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><script>var uuid = "UUID-TEST-1";</script></html>`)
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
n := atomic.AddInt32(&h.polls, 1)
w.Header().Set("Content-Type", "text/plain")
if n == 1 {
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
return
}
if n < atomic.LoadInt32(&h.confirmAt) {
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
return
}
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
case r.URL.Path == "/login/thirdAccount/bind/auth":
h.gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "loomy",
"wechat": map[string]interface{}{
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
"poll_url": h.ts.URL + "/connect/l/qrconnect",
"account_base": h.ts.URL,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *loomyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *loomyHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
func TestLoomyWechatPollBehavior(t *testing.T) {
h := newLoomyHarness(t)
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "UUID-TEST-1") {
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
}
})
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 99)
_, sid := h.begin()
// 第一拍 408
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("408 应 pending, 实得 %v", st["state"])
}
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("★ 404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
}
})
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 3)
_, sid := h.begin()
h.pollOnce(sid) // 408
h.pollOnce(sid) // 404
st := h.pollOnce(sid) // 405
if st["state"] != "completed" {
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
}
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
if !strings.HasPrefix(h.gotAuth, "account ") {
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
}
if !strings.Contains(h.gotAuth, ":") {
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
if !ok || !strings.Contains(v, "RCODE-1") {
t.Fatalf("凭据应含 rcode, 实得 %q", v)
}
})
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
for _, tc := range []struct{ errcode, want string }{
{"403", "取消"}, {"402", "失效"},
} {
hh := newLoomyHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
fmt.Fprint(w, `<script>var uuid = "U";</script>`)
return
}
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "failed" {
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
}
}
})
}

View File

@ -0,0 +1,181 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// qoderHarness:Qoder PKCE 设备码轮的测试台。
//
// 参考语义(qoder-oauth.ts / qoder.ts 注释点名):
//
// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
// &nonce&machine_id&client_id —— **本地拼装,不打网络**
// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
//
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
type qoderHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
poll int32 // 已发生的轮询次数
// step: 第几次轮询开始返回 token(0 = 一直 404)
tokenAt int32
gotQuery string
}
func newQoderHarness(t *testing.T) *qoderHarness {
t.Helper()
h := &qoderHarness{t: t, tokenAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/deviceToken/poll" {
h.gotQuery = r.URL.RawQuery
n := atomic.AddInt32(&h.poll, 1)
if n < atomic.LoadInt32(&h.tokenAt) {
w.WriteHeader(404) // 尚无会话
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
return
}
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
return
}
w.WriteHeader(404)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *qoderHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
func TestQoderDevicePollBehavior(t *testing.T) {
h := newQoderHarness(t)
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
j, _ := h.begin()
url, _ := j["url"].(string)
for _, want := range []string{
"qoder.com/device/selectAccounts",
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
} {
if !strings.Contains(url, want) {
t.Fatalf("授权页缺 %q:\n%s", want, url)
}
}
})
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
_, sid := h.begin()
for i := 0; i < 3; i++ {
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99)
_, sid := h.begin()
h.pollOnce(sid)
q := h.gotQuery
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
}
if !strings.Contains(q, "challenge_method=S256") {
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
}
})
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
}
// 凭据落保险库(旧路径键)
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
if !ok || !strings.Contains(v, "QD-AT-1") {
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
}
})
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
t.Fatalf("★ 池路径凭据未落到 channels:qoder:%s", aid)
}
})
}

View File

@ -0,0 +1,188 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
//
// 参考语义(zcode-login.ts 逐行):
//
// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec}
// Authorization 是**本地造的会话密钥**(不是用户凭据)
// ② 用户在浏览器打开 authorize_url
// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等;
// code!=0 或 data 缺失 = pending;status=pending = 继续
//
// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha**
// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。
type zcodeHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
readyAt int32
gotInit map[string]interface{}
gotAuth string
}
func newZcodeHarness(t *testing.T) *zcodeHarness {
t.Helper()
h := &zcodeHarness{t: t, readyAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init":
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &h.gotInit)
h.gotAuth = r.Header.Get("Authorization")
fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT",
"authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x",
"expires_at":9999999999,"poll_interval_sec":2}}`)
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"):
if !strings.HasSuffix(r.URL.Path, "FLOW-1") {
fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`)
return
}
n := atomic.AddInt32(&h.polls, 1)
if n < atomic.LoadInt32(&h.readyAt) {
fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"ready",
"token":"ZCODE-JWT-1",
"user":{"user_id":"u7","name":"zcode-dev"},
"bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "zcode",
"initpoll": map[string]interface{}{
"init_url": h.ts.URL + "/api/v1/oauth/cli/init",
"poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *zcodeHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。
func TestZcodeInitPollBehavior(t *testing.T) {
h := newZcodeHarness(t)
t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
if h.gotInit["provider"] != "bigmodel" {
t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit)
}
// Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据
if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 {
t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth)
}
if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") {
t.Fatalf("应给出 authorize_url, 实得 %q", u)
}
})
t.Run("行2 pending状态保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 2)
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("ready 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:zcode")
if !ok {
t.Fatal("凭据未落库")
}
for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} {
if !strings.Contains(v, want) {
t.Fatalf("凭据应含 %q, 实得 %s", want, v)
}
}
})
t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
if st := h.pollOnce(sid); st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
t.Fatalf("★ 池路径凭据未落到 channels:zcode:%s", aid)
}
})
}

View File

@ -456,15 +456,40 @@ local PLATFORMS = {
claim_fn_name = "claimCodeArts",
},
-- Qoder —— PKCE 设备码 + WASM 加密签名(照参考 qoder-oauth.ts 逆向)。
-- WASM 签名头无法在纯 Lua 环境完成,登录标 manual 给明确说明。
-- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。
--
-- ⚠️ 纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。
-- 那是把**聊天加密**(WASM 用于 /algo/ 加密推理端点)与**登录**混为一谈 ——
-- 登录本身是纯 HTTP 的 PKCE 设备码流,Lua 完全能做:
-- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256
-- &nonce&machine_id&client_id
-- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
-- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
-- 三个必须照抄的细节(参考注释都点了名):
-- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝)
-- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401)
-- · nonce/verifier 必须原样回传(PKCE 校验)
{
id = "qoder",
name = "Qoder",
vendor = "qoder",
flow = "manual",
manual_hint = "Qoder 登录要 WASM 签名头,纯 Lua 环境无法生成。请在浏览器完成"
.. "授权后,把最终凭证粘贴进来(手动输入凭据)。",
flow = "device-poll",
device = {
-- 授权页在 {authBase}/device/selectAccounts,query 由 pkce 表单拼装
code_url = "https://qoder.com/device/selectAccounts",
code_method = "GET_QUERY",
-- code_url 的 query 模板:{challenge}/{nonce}/{machine_id}/{client_id}
auth_query = "challenge={challenge}&challenge_method=S256&nonce={nonce}"
.. "&machine_id={machine_id}&client_id=" .. "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
-- 轮询在 openApiBase
token_url = "https://openapi.qoder.sh/api/v1/deviceToken/poll",
token_method = "GET_QUERY",
-- poll 的 query 模板:PKCE 用 verifier(不是 challenge)
poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256",
-- 判据:404 = 继续(见 deviceOneStep 的 pending404 分支)
judge = "pending404",
interval = 2,
},
base_url = "https://api2-v2.qoder.sh",
adapter = "openai",
models = { "qoder-default" },
@ -482,17 +507,53 @@ local PLATFORMS = {
},
},
-- ZCode —— cli/init + captcha(照参考 zcode-auth.ts 逆向)。
-- captcha 求解(mint captcha + 补激活上报)无法在纯 Lua 完成,标 manual。
-- ZCode —— **两段式服务端中介授权流**(照参考 zcode-login.ts 逐行移植)。
--
-- ⚠️ 纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考
-- 后发现**该授权流完全不涉及 captcha** —— captcha 属于另一条已废弃的旁路
-- (zcode-captcha.ts,用于读取本机官方客户端数据),而本流的注释明确写着
-- 「完全不经 zcode:// 自定义协议回调,故普通进程就能走完」:
-- ① POST {origin}/api/v1/oauth/cli/init
-- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)>
-- body {provider:"bigmodel"}
-- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec}
-- ② 用户在浏览器打开 authorize_url 完成授权
-- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id}
-- Authorization: Bearer <同一个 flow_secret>
-- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai}
-- 判据(参考注释点名,与官方一致):
-- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试
-- · code != 0 或 data 缺失 → pending
-- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一
{
id = "zcode",
name = "ZCode",
vendor = "zcode",
flow = "manual",
manual_hint = "ZCode 登录要解 captcha(client/configs 拿 sceneId 后 mint),"
.. "纯 Lua 环境无法完成。请完成 captcha 验证后,把最终凭证粘贴进来。",
base_url = "https://zcode.example.com/v1",
adapter = "openai",
flow = "init-poll",
initpoll = {
init_url = "https://zcode.z.ai/api/v1/oauth/cli/init",
poll_url = "https://zcode.z.ai/api/v1/oauth/cli/poll/{flow_id}",
provider = "bigmodel",
app_version = "3.14.3",
origin = "https://zcode.z.ai",
interval = 2,
},
-- 推理走 Anthropic 协议的免费额度通道(照参考 zcode-upstream.ts):
-- 积分制无论 bigmodel/zai 都走 zcode.z.ai(官方分派表规则 2/5,2026-10-03 实测);
-- 只有订阅制才走 api.z.ai。这里用积分制那条。
base_url = "https://zcode.z.ai",
endpoint = "/api/v1/zcode-plan/anthropic/v1/messages",
adapter = "anthropic",
-- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL)
balance = {
url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance",
method = "GET",
total_fn = function(rec)
local d = rec.data or rec
-- 优先取明确的余额字段;取不到就不编造(返回 nil 让调用方报错)
return tonumber(d.balance or d.remaining or d.credits or d.total)
end,
},
models = { "zcode-default" },
},
@ -502,10 +563,32 @@ local PLATFORMS = {
id = "loomy",
name = "Loomy",
vendor = "loomy",
flow = "manual",
manual_hint = "Loomy 登录要微信扫码(在微信公众号里完成授权)。请在微信里"
.. "扫码后,把拿到的凭证粘贴进来。",
base_url = "https://loomy.example.com/v1",
-- ⚠️ 纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。
-- 扫码动作确实在用户手机上,但**协议全是纯 HTTP/Lua 可做**:
-- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid
-- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染)
-- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_
-- 响应体里 wx_errcode=NNN / wx_code='...'
-- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders)
-- 判据(参考点名;早期版本**读反了**导致"扫码后卡在已扫码"):
-- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧);
-- 403=用户取消;402=二维码失效
flow = "wechat-poll",
wechat = {
auth_url = "https://open.weixin.qq.com/connect/qrconnect"
.. "?appid=wx16e5c2b8e0c0e1f2&scope=snsapi_login&response_type=code"
.. "&redirect_uri=https%3A%2F%2Floomy.xunfei.cn%2Foauth%2Fwechat%2Fcallback&state={state}",
qr_url = "https://open.weixin.qq.com/connect/qrcode/{uuid}",
poll_url = "https://long.open.weixin.qq.com/connect/l/qrconnect",
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
.. " (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
account_base = "https://account.xfyun.cn",
bind_path = "/login/thirdAccount/bind/auth",
access_key_id = "2thryby66wxi53sk",
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
app_id = "GM3LOOMY",
},
base_url = "https://loomy.xunfei.cn",
adapter = "openai",
models = { "loomy-default" },
},
@ -593,6 +676,26 @@ local function declaredHosts()
local h = (p.checkin and p.checkin.url or ""):match("^https?://([^/:]+)")
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
end
-- 各 flow 自己的端点域名也要(否则授权页/长轮询/账号端点会被出站白名单拦)。
-- 这些域名与 base_url 往往不同:Loomy 的授权在 open.weixin.qq.com、
-- 长轮询在 long.open.weixin.qq.com、账号在 account.xfyun.cn;
-- ZCode 的 init/poll 在 zcode.z.ai(与 base_url 同域但显式声明更清楚)。
local function addHost(u)
local h = (u or ""):match("^https?://([^/:]+)")
if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end
end
for _, p in ipairs(PLATFORMS) do
if p.wechat then
addHost(p.wechat.auth_url); addHost(p.wechat.qr_url)
addHost(p.wechat.poll_url); addHost(p.wechat.account_base)
end
if p.initpoll then addHost(p.initpoll.init_url); addHost(p.initpoll.poll_url) end
if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end
if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end
if p.balance then addHost(p.balance.url) end
if p.checkin_status then addHost(p.checkin_status.url) end
if p.callback then addHost(p.callback.exchange_url) end
end
table.sort(out)
return out
end
@ -643,6 +746,102 @@ local function hexToBin(h)
return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end))
end
-- ---- 讯飞账号(CAccount)HMAC-SHA1 签名(Loomy 用)----
-- 逐段复刻参考 loomy-sign.ts。任何偏差都会让账号端点返回鉴权失败。
--
-- 内核只有 sha256/hmac_sha256 —— 没有 sha1,也没有 md5。但插件有完整 OS 能力,
-- 直接用 openssl(本机已装)算 HMAC-SHA1 与 MD5,不在 Lua 里重写密码学原语。
--
-- 签名字符串 9 段以 \n 连接,**后两段恒为空串**(不发 x-* 头),
-- 故最终字符串以两个换行结尾 —— 不要"顺手"去掉尾随换行。
-- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。
-- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) *
local function rfc3986Escape(v)
v = tostring(v or "")
v = v:gsub("([^A-Za-z0-9%-%._~])", function(c)
return string.format("%%%02X", string.byte(c))
end)
-- encodeURIComponent 不转 ! ' ( ) * ~,但参考的 escapeRfc3986 要转前五个
v = v:gsub("!", "%%21"):gsub("'", "%%27"):gsub("%(", "%%28")
:gsub("%)", "%%29"):gsub("%*", "%%2A")
return v
end
-- opensslDigest:调 openssl 算摘要。mode 是 openssl 的算法名(md5/sha1)。
-- hmacKey 非空时用 -hmac;输出 base64(参考用的就是 base64)。
local function opensslDigest(mode, data, hmacKey)
-- 用临时文件传原文:命令行的引号/换行/二进制都不可靠
local inPath = "/tmp/llmsproxy-sig-in-" .. tostring(math.random(1e12))
local f = io.open(inPath, "wb")
if not f then return nil, "无法写临时文件" end
f:write(data)
f:close()
local cmd
if hmacKey and hmacKey ~= "" then
local keyPath = inPath .. ".key"
local kf = io.open(keyPath, "wb")
if not kf then os.remove(inPath) return nil, "无法写密钥文件" end
kf:write(hmacKey)
kf:close()
-- 外层用单引号包 Lua 字符串,内部才能安全地带 shell 的双引号
cmd = ('openssl dgst -%s -hmac "$(cat %s)" -binary %s 2>/dev/null | base64 -w0')
:format(mode, keyPath, inPath)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(keyPath)
os.remove(inPath)
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
end
cmd = ("openssl dgst -%s -binary %s 2>/dev/null | base64 -w0"):format(mode, inPath)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
os.remove(inPath)
return (out ~= "" and (out:gsub("%s+$", ""))) or nil
end
-- loomyContentMd5:空 body 返回**空串**(不是空串的 md5),与客户端一致。
local function loomyContentMd5(body)
if not body or body == "" then return "" end
return opensslDigest("md5", body, nil) or ""
end
-- loomySigningString:9 段拼串(method,path,query,md5,ctype,date,nonce,"","")
local function loomySigningString(method, path, query, body, contentType, date, nonce)
local md5 = loomyContentMd5(body)
return table.concat({
method:upper(), path, query or "", md5, contentType or "",
date, nonce, "", "",
}, "\n")
end
-- loomyAuthHeaders:生成讯飞账号请求头。
local function loomyAuthHeaders(ak, aks, method, path, body, contentType)
local date = os.date("!%a, %d %b %Y %H:%M:%S GMT")
-- nonce 只要唯一即可(服务端不校验格式)。这里**不能调 newToken** ——
-- 它定义在文件后段(1518 行),Lua 词法作用域下此处是 nil,调用会崩。
-- 用内核全局 sha256_hex(随时可用)造一个 32 字节 hex,与参考的 UUID 同量级。
local nonce = string.format("%s-%s-%s",
sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))):sub(1, 8),
sha256_hex(tostring(math.random(1e12))):sub(1, 4),
sha256_hex(tostring(math.random(1e12))):sub(1, 12))
local ctype = contentType or "application/json"
local sts = loomySigningString(method, path, "", body, ctype, date, nonce)
local sig, err = opensslDigest("sha1", sts, aks)
if not sig then return nil, err or "签名计算失败" end
local h = {
["Authorization"] = "account " .. ak .. ":" .. sig,
["Date"] = date,
["Nonce"] = nonce,
["Content-Type"] = ctype,
}
local md5 = loomyContentMd5(body)
if md5 ~= "" then h["Content-MD5"] = md5 end
return h
end
-- devicePkce 生成一次授权的 verifier/challenge 对(SHA256 S256)。
-- verifier 自造:熵拼接后哈希。不依赖文件后段才定义的 newToken —— Lua 词法
-- 作用域下那会是 nil 全局调用。熵用 os.time + math.random 两次哈希拼接。
@ -660,13 +859,26 @@ end
--(slow_down 后 interval 增大,与参考一致)。
local function deviceOneStep(plat, sess)
local d = plat.device
local body = (d.token_body
:gsub("{device_code}", sess.device_code)
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or ""))
local url, method, body = d.token_url, d.token_method or "POST", nil
if d.poll_query or (d.token_method == "GET_QUERY") then
-- 形态②(Qoder):GET + query 模板。PKCE 用 verifier(不是 challenge),
-- 且 nonce 必须是**会话创建时那一个**(服务端按它找回待授权会话)。
local q = (d.poll_query or "")
:gsub("{nonce}", sess.device_nonce or sess.nonce or "")
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or "")
:gsub("{device_code}", sess.device_code or "")
url = d.token_url .. "?" .. q
method = "GET"
else
body = (d.token_body
:gsub("{device_code}", sess.device_code or "")
:gsub("{verifier}", sess.device_verifier or "")
:gsub("{challenge}", sess.device_challenge or ""))
end
local resp, err = __llmsproxy_http({
url = d.token_url,
method = "POST",
url = url,
method = method,
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
@ -685,6 +897,29 @@ local function deviceOneStep(plat, sess)
local ecode = rec.error
local step = tonumber(d.slow_down_step) or 5
-- 「404/pending = 继续轮询」形态(Qoder)。
--
-- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而
-- openapi.qoder.sh 对「无待授权会话」返回 **404**,语义是"还没有会话,继续等"
-- 而非失败。把 404 当失败会让登录永远失败。
-- 判定由平台声明 judge="pending404" 驱动,不在这里硬编码平台名。
if d.judge == "pending404" then
if resp.status == 404 then return false end
if resp.status == 200 then
-- 2xx 但尚无 token:同样继续轮询
local tok = rec.token or rec.device_token or rec.access_token
if tok == nil or tok == "" then return false end
return true, { ok = true, access_token = tok,
refresh_token = rec.refresh_token or rec.refreshToken,
account = rec.user_name or rec.userName or "",
uid = rec.user_id or rec.userId,
expires_at = rec.expires_at or rec.expiresAt,
refresh_expires_at = rec.refresh_token_expires_at or rec.refreshTokenExpiresAt }
end
return true, { ok = false, reason = "error",
message = "登录服务返回异常(HTTP " .. tostring(resp.status) .. ")" }
end
if workos then
if ecode == "authorization_pending" then return false end
if ecode == "slow_down" then
@ -731,6 +966,56 @@ local function deviceOneStep(plat, sess)
return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) }
end
-- wechatExchange:把微信 wx_code 换成 Loomy 凭据(讯飞账号端点,HMAC-SHA1 签名)。
--
-- 参考链路(loomy-oauth.ts 注释):bindAuthThirdAccount(code) → {bind, rcode, nickname}
-- → 若已绑手机号可 bindSkip(rcode) 直接拿 session;未绑则要走 SMS(需人工输入验证码)。
-- 这里做到 bindAuth 一步:拿到 rcode/nickname 即视为可用的登录上下文,
-- 并把 bind 标志带进凭据,让上层知道是否需要补手机号(不假装 SMS 已完成)。
local function wechatExchange(plat, wxCode)
local w = plat.wechat
if not w then return nil, "平台未声明 wechat 配置" end
local path = w.bind_path
local body = json.encode({
tcode = { code = wxCode },
type = "wx",
appId = w.app_id,
})
local headers, herr = loomyAuthHeaders(w.access_key_id, w.access_key_secret,
"POST", path, body, "application/json")
if not headers then return nil, "签名失败: " .. tostring(herr) end
headers["Accept"] = "application/json"
local resp, err = __llmsproxy_http({
url = w.account_base .. path, method = "POST", headers = headers, body = body,
})
if err then return nil, "账号端点请求失败: " .. err end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return nil, "账号端点响应无法解析(HTTP " .. tostring(resp.status) .. ")"
end
-- 参考兼容 data 包裹与顶层两种形态
local data = type(rec.data) == "table" and rec.data or rec
local rcode = data.rcode
if type(rcode) ~= "string" or rcode == "" then
return nil, "微信授权响应缺少 rcode(code 可能已失效)"
end
-- bind 缺失归 0(走绑定流程)—— 保守方向:已绑最多多填一次手机号;
-- 未绑却跳过会拿到没有手机号的账号。
local bind = (data.bind == 1) and 1 or 0
return json.encode({
access_token = rcode, -- 后续所有步骤用 rcode(wx_code 只用这一次)
rcode = rcode,
bind = bind,
session = data.session or data.token,
user_id = data.userid or data.user_id,
nickname = data.nickname,
headpic = data.headpic,
phone_bound = bind == 1,
token_type = "Bearer",
account_type = "personal",
}), nil
end
-- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。
-- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式
-- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。
@ -1819,14 +2104,109 @@ function plugin.start_login(req)
sess.collect_expr = plat.collect_expr
logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")")
end
elseif plat.flow == "wechat-poll" then
-- 微信扫码:取 uuid → 把二维码给前端渲染 → 长轮询等扫码确认。
-- uuid 从授权页 HTML 里抽(参考 extractLoomyWechatUuid 的两条正则兜底)。
local w = plat.wechat
local authUrl = w.auth_url:gsub("{state}", sess.nonce)
local resp, err = __llmsproxy_http({
url = authUrl, method = "GET",
headers = { ["Accept"] = "text/html", ["User-Agent"] = w.user_agent,
["Referer"] = "https://open.weixin.qq.com/" },
})
if err or not resp or resp.status ~= 200 then
sess.state = "failed"
out.error = "微信授权页拉取失败: " .. (err or ("HTTP " .. tostring(resp and resp.status)))
return { status = 200, json = out }
end
local html = resp.body or ""
-- 主正则 + 兜底(含 uuid 的查询串)
local uuid = html:match('uuid%s*=%s*"([^"]+)"')
or html:match("uuid%s*=%s*'([^']+)'")
or html:match("[?&]uuid=([%w%-_]+)")
if not uuid or uuid == "" then
sess.state = "failed"
out.error = "微信授权页未包含二维码 uuid(页面结构可能已变化)"
return { status = 200, json = out }
end
sess.wechat_uuid = uuid
sess.wechat_last = ""
-- 二维码图交给前端渲染(参考把 JPEG 转 data URL;这里给前端 URL,省一次下载)
out.qr_url = w.qr_url:gsub("{uuid}", uuid)
out.url = authUrl
logEvent("微信二维码已就绪 " .. plat.id)
elseif plat.flow == "init-poll" then
-- 两段式服务端中介授权(ZCode):init 拿 flow_id + authorize_url,
-- 用户授权后轮询 poll/{flow_id} 拿 token。
-- 会话密钥是**本地造的 32 字节 hex**,作 Bearer 用(官方就是这么做的)。
local ip = plat.initpoll
local flowSecret = sha256_hex(newToken() .. tostring(os.time and os.time() or 0))
.. sha256_hex(newToken())
local resp, err = __llmsproxy_http({
url = ip.init_url,
method = "POST",
headers = {
["Accept"] = "application/json",
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. flowSecret,
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
["HTTP-Referer"] = ip.origin or "",
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
["X-Platform"] = "win32",
},
body = json.encode({ provider = ip.provider or "bigmodel" }),
})
if err then
sess.state = "failed"
out.error = "授权初始化失败: " .. err
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
local data = ok and type(rec) == "table" and rec.data or nil
if not data or type(data.flow_id) ~= "string" or data.flow_id == "" then
sess.state = "failed"
out.error = "授权初始化响应缺少 flow_id(HTTP " .. tostring(resp.status) .. ")"
return { status = 200, json = out }
end
sess.init_flow_id = data.flow_id
sess.init_secret = flowSecret
sess.device_interval = tonumber(data.poll_interval_sec) or ip.interval or 2
out.url = data.authorize_url or plat.login_url
out.session.interval = sess.device_interval
logEvent("授权流程已初始化 " .. plat.id .. " flow=" .. tostring(data.flow_id):sub(1, 12))
elseif plat.flow == "device-poll" then
-- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。
-- 行为对齐参考 startMinimaxDeviceAuthorization:PKCE S256。
--
-- 两种形态:
-- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code,
-- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。
-- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把
-- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页,
-- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。
-- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。
local verifier, challenge = devicePkce()
local machineId = newToken():sub(1, 32)
local dm = plat.device.code_method or "POST"
local rec = nil
if dm == "GET_QUERY" or plat.device.auth_query then
-- 形态②:授权页 URL 由模板拼装,无网络申请
local q = (plat.device.auth_query or "")
:gsub("{challenge}", challenge):gsub("{nonce}", sess.nonce)
:gsub("{machine_id}", machineId):gsub("{verifier}", verifier)
out.url = plat.device.code_url .. "?" .. q
sess.device_verifier = verifier
sess.device_challenge = challenge
sess.device_nonce = sess.nonce
sess.device_machine = machineId
sess.device_interval = plat.device.interval or 2
out.session.interval = sess.device_interval
logEvent("授权页已就绪 " .. plat.id)
return { status = 200, json = out }
end
local body = (plat.device.code_body:gsub("{challenge}", challenge))
local resp, err = __llmsproxy_http({
url = plat.device.code_url,
method = "POST",
method = dm,
headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" },
body = body,
})
@ -1835,8 +2215,9 @@ function plugin.start_login(req)
out.error = "设备码申请失败: " .. err
return { status = 200, json = out }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" or not rec.device_code then
local ok, r2 = pcall(json.decode, resp.body or "")
rec = ok and type(r2) == "table" and r2 or nil
if not rec or not rec.device_code then
sess.state = "failed"
out.error = "设备码响应无法解析(HTTP " .. tostring(resp.status) .. ")"
return { status = 200, json = out }
@ -1881,7 +2262,11 @@ function plugin.poll_login(req)
local s = sessions()[id]
if not s then return { status = 200, json = { state = "gone" } } end
if s.flow == "device-poll" and s.state == "pending" and s.device_code then
-- 条件不能要求 device_code:Qoder 形态是 PKCE 授权页,**没有 device_code**
-- (本地造 verifier/challenge,服务端按 nonce 找回待授权会话)。
-- 用「有 device_code 或有 device_verifier」表达"这是个可推进的设备码/授权页会话"。
if s.flow == "device-poll" and s.state == "pending"
and (s.device_code or s.device_verifier) then
local plat = platformById(s.platform)
local done, payload = deviceOneStep(plat, s)
if done and payload then
@ -1909,6 +2294,135 @@ function plugin.poll_login(req)
s.message = payload.message
end
end
elseif s.flow == "wechat-poll" and s.state == "pending" and s.wechat_uuid then
-- 长轮询一步:判据照参考(**404 与 405 不可读反**)。
local plat = platformById(s.platform)
local w = plat and plat.wechat
if w then
local last = s.wechat_last or ""
local url = w.poll_url .. "?uuid=" .. s.wechat_uuid
.. (last ~= "" and ("&last=" .. last) or "")
.. "&_=" .. tostring(os.time and os.time() or 0)
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = { ["Accept"] = "*/*", ["User-Agent"] = w.user_agent,
["Referer"] = w.auth_url:sub(1, (w.auth_url:find("?")) and w.auth_url:find("?") - 1 or #w.auth_url) },
})
if not err and resp then
local body = resp.body or ""
local errcode = body:match("wx_errcode%s*=%s*(%d+)") or ""
local code = body:match("wx_code%s*=%s*'([^']*)'") or ""
s.wechat_last = errcode
if errcode == "405" then
-- **已确认**(wx_code 在此帧)。405 却没带 code 是异常形态,
-- 保守判"已扫码"继续轮询,**绝不**拿空 code 去换凭据。
if code ~= "" then
local cred, cerr = wechatExchange(plat, code)
if cerr then
s.state = "failed"
s.message = cerr
else
local acct = "wechat"
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
elseif errcode == "404" then
-- 已扫码待确认:继续轮询(**不是**完成 —— 早期读反导致卡住)
s.wechat_hint = "已扫码,请在手机上确认"
elseif errcode == "403" then
s.state = "failed"
s.message = "用户取消了授权"
elseif errcode == "402" then
s.state = "failed"
s.message = "二维码已失效,请重新发起登录"
end
-- 408 与未知值一律继续等(保守:绝不误判成功)
end
end
elseif s.flow == "init-poll" and s.state == "pending" and s.init_flow_id then
-- 轮询 poll/{flow_id}:判据照参考 —— 4xx(除 408/429)才是终态失败,
-- 5xx 与网络错误继续重试;code!=0 或 data 缺失 → pending。
local plat = platformById(s.platform)
local ip = plat and plat.initpoll
if ip then
local url = ip.poll_url:gsub("{flow_id}", s.init_flow_id)
local resp, err = __llmsproxy_http({
url = url, method = "GET",
headers = {
["Accept"] = "application/json",
["Authorization"] = "Bearer " .. (s.init_secret or ""),
["User-Agent"] = "ZCode/" .. (ip.app_version or "3.14.3"),
["HTTP-Referer"] = ip.origin or "",
["X-ZCode-App-Version"] = ip.app_version or "3.14.3",
},
})
if not err and resp then
local st = resp.status
local terminal = st >= 400 and st < 500 and st ~= 408 and st ~= 429
if terminal then
s.state = "failed"
s.message = "轮询被拒(HTTP " .. tostring(st) .. ")"
else
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" and rec.code == 0 and type(rec.data) == "table" then
local d = rec.data
local status = d.status
if status == "failed" then
s.state = "failed"
s.message = "用户拒绝了授权或授权失败"
elseif status == "ready" then
-- token(zcode JWT)与 user.user_id 必需;第三方 token 取
-- bigmodel/zai 二者之一(参考按 provider 取对应分支)。
local jwt = d.token
local user = d.user or {}
local pick = function(blk)
if type(blk) ~= "table" then return nil, nil end
return blk.access_token or blk.accessToken, blk.refresh_token or blk.refreshToken
end
local bmAt, bmRt = pick(d.bigmodel)
local zaiAt, zaiRt = pick(d.zai)
local thirdAt = bmAt or zaiAt
if type(jwt) ~= "string" or jwt == "" then
s.state = "failed"
s.message = "授权响应缺少 zcode token"
else
local cred = json.encode({
access_token = jwt,
zcode_jwt = jwt,
-- 第三方 token 仅作备用身份,不参与推理(参考明确)
bigmodel_access_token = bmAt,
zai_access_token = zaiAt,
refresh_token = bmRt or zaiRt,
user_id = user.user_id or user.id,
nickname = user.name or user.email,
token_type = "Bearer",
account_type = "personal",
})
local acct = user.name or user.email or user.user_id or "zcode"
local res = plugin.complete_login({ platform = s.platform, credential = cred,
account = acct, account_id = s.account_id })
if res.json and res.json.ok then
s.state = "completed"
s.account = acct
else
s.state = "failed"
s.message = (res.json and res.json.error) or "接入源失败"
end
end
end
-- status == "pending" 或其它未知值:继续等(不误报)
end
end
end
end
elseif s.flow == "state-poll" and s.state == "pending" and s.state_token then
-- Buddy(CodeBuddy):state 轮询。状态里的 state_token 是 auth/state 下发的。
local plat = platformById(s.platform)
@ -3007,6 +3521,23 @@ function plugin.set_endpoints(req)
for k in pairs(allowed) do
if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end
end
if type(args.wechat) == "table" and plat.wechat then
for _, k in ipairs({ "auth_url", "qr_url", "poll_url", "account_base",
"bind_path", "user_agent", "access_key_id", "access_key_secret", "app_id" }) do
if args.wechat[k] then
plat.wechat[k] = tostring(args.wechat[k])
changed[#changed+1] = "wechat." .. k
end
end
end
if type(args.initpoll) == "table" and plat.initpoll then
for _, k in ipairs({ "init_url", "poll_url", "provider", "origin" }) do
if args.initpoll[k] then
plat.initpoll[k] = tostring(args.initpoll[k])
changed[#changed+1] = "initpoll." .. k
end
end
end
if type(args.device) == "table" and plat.device then
for _, k in ipairs({ "code_url", "token_url" }) do
if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end