mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户报障:「除了 codebuddy,其他的登陆都不太正常,尤其是有道那个」。逐平台审计
参考实现(vendor/channel-pack/src)后发现根因不是 12 个独立 bug,而是几个系统性偏差。
## ① callback 流的回调是回环地址 → 必须走服务端浏览器(核心认识)
Trae/Gemini/有道 的 redirect_uri 都是回环地址(127.0.0.1 / localhost)。回环地址由
「打开授权页的那台机器」解析:远程用户在自己电脑上打开,重定向就落在**用户机器**上,
回调永远到不了网关。CodeBuddy 一直是好的,因为它走 state-poll,**根本不需要回调**。
→ 插件自起 loopback 接收器(Python,绑回环、双栈,路径按各平台注册值),
由服务端浏览器(VNC)承载授权页,重定向落回本机。
## ② 三个平台的回调路径各是注册值(写错就是 redirect_uri_mismatch)
有道 /auth/callback、Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)
且 Gemini 的 redirect_uri 主机是 **localhost** 而不是 127.0.0.1。
另:Trae 的回调**不带 state**(token 直接放 query),其余平台必须带 → 校验规则按形态分。
## ③ 有道版本号不能写死
exchange/续期/签到的 version 是必填。实测 2026-10-13 上游已是 2026.9.23,而参考的兜底
常量还停在 2026.9.4。新增 lobsteraiClientVersion:远端为主(data.value.version,**非统一
信封**)+ 失败用兜底但**不缓存**(照参考 resolver)。失败时把上游 message 带出来
(实测形如 code=40102「无效或已过期的授权码」)。
## ④ Gemini token 交换缺 client_secret(症状正是「授权成功但账号不出现」)
参考注释点名:token 端点强制校验客户端身份,只发 client_id 会 invalid_request。
同时补 access_type=offline / prompt=consent(缺了拿不到 refresh_token),
并去掉误标的 pkce(上游 Cloud Code 用的是最朴素的 authorization_code + client_secret)。
## ⑤ Trae 授权 URL 少了 5 个必需参数
参考列为「一个都不能少」:login_trace_id(回调**反查 pending 的唯一凭据**)、
machine_id/device_id(hex32,每账号必须不同,否则被「该设备已签到」拦)、
x_device_id/x_machine_id。此前**完全没造**设备身份 → 登录页停在「授权中」。
## ⑥ Trae 签到头与设备身份
授权方案是 `Cloud-IDE-JWT`(不是 Bearer);参考点明简化头**实测签到失败**,
需约 20 个客户端头,且 X-Device-Id 是**基于 user_id 确定性派生的 15 位数字**。
实现 seededStream(SHA256(utf8(salt:seed) ++ counterBE32) 串联)+ 三级派生,
并用参考实现(Node)生成期望值做**差分验证**逐字节对齐。
凭据改为 JSON 以保留 uid/device_id/machine_id(签到与轮换需要)。
## ⑦ 同段逻辑抽一个助手,防两条路漂移
lobsteraiExchange 被 oauth_callback 与 poll_login 两条路共用(原先各内联一份)。
## 判据(新增测试)
- TestLobsterLoopbackClosedLoop / TestLobsterExchangePreservesIdentity:loopback 闭环、
state 不匹配 400、重放 409、凭据含 uuid/first_keyfrom
- TestCallbackPlatformsLoopback:三个平台的路径与 state 规则、Trae 无 state 也能接住
- TestAuthorizeURLsHaveNoUnfilledPlaceholders:授权 URL 不留 {占位符}
- TestTraeDeriveMatchesReference:与参考实现**逐字节**差分(tools/trae_derive_expected.mjs)
- TestTraeCheckinHeaders:Cloud-IDE-JWT + 派生设备身份 + 客户端头齐全
- TestRemoteModelCatalogMergesWithFallback:远端为主、兜底补位、失败不清缓存
## 顺带
- set_endpoints 补 callback / client_version 键(否则测试无法把端点指到假上游)
- 删掉 spawnBrowser 里会把 redirect_uri 一律改写为插件挂载点的死代码(对有道致害)
- session 过期与插件卸载时回收 loopback 接收器进程(防进程泄漏)
- e2e/channels_flows_e2e_test.go:qoder 已从 manual 改为 device-poll,断言同步更新
112 lines
4.1 KiB
Go
112 lines
4.1 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200,
|
||
// 但头不对就只是一个失败/风控响应。参考实现明确记录:
|
||
//
|
||
// ① 授权方案是 Cloud-IDE-JWT,**不是** Bearer(用错前缀是 401 级失败);
|
||
// ② 简化头(只有 Authorization + 几个 Ug 头)**实测签到失败**,
|
||
// 真正成功的是约 20 个客户端头;
|
||
// ③ X-Device-Id 不是凭据里的 hex32,而是**基于 user_id 确定性派生的 15 位数字**
|
||
// (每账号天然不同设备,所以不再需要 9074 时换设备号重试)。
|
||
//
|
||
// 本测试按参考逐条锁定这些事实,并把派生的 device_id 钉死到与参考一致的值。
|
||
func TestTraeCheckinHeaders(t *testing.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")
|
||
w.Write([]byte(`{"code":0}`))
|
||
}))
|
||
defer ts.Close()
|
||
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 把签到端点指到假上游(不覆盖 checkin.auth,验证声明真的在起作用)
|
||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "trae",
|
||
"checkin": map[string]interface{}{"url": ts.URL},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// uid 必须是真值:设备身份由它派生。用参考实现算出的期望值(见 tools/trae_derive_expected.mjs)
|
||
const uid = "12345"
|
||
// credential 传**字符串**(凭据是一段 JSON 文本),不是对象 —— complete_login
|
||
// 会原样存进保险库,取用时 credentialOf 再解出 access_token。
|
||
cred, _ := json.Marshal(map[string]interface{}{
|
||
"access_token": "ACCESS-1", "uid": uid, "device_id": "deadbeef",
|
||
})
|
||
body, _ := json.Marshal(map[string]string{
|
||
"platform": "trae", "credential": string(cred), "account": "t",
|
||
})
|
||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin", Body: string(body),
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin", Body: `{"platform":"trae"}`,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("claim_daily: %v", err)
|
||
}
|
||
if j, _ := out.JSON.(map[string]interface{}); j != nil && j["error"] != nil {
|
||
t.Fatalf("claim 失败: %v(完整响应 %v)", j["error"], j)
|
||
}
|
||
|
||
t.Run("行1 授权方案是 Cloud-IDE-JWT", func(t *testing.T) {
|
||
if v := got["Authorization"]; v != "Cloud-IDE-JWT ACCESS-1" {
|
||
t.Fatalf("Authorization 必须是 Cloud-IDE-JWT <token>(Bearer 会 401),实得 %q", v)
|
||
}
|
||
})
|
||
t.Run("行2 设备身份是按 user_id 派生的 15 位数字", func(t *testing.T) {
|
||
// 期望值来自参考实现(Node + node:crypto)的派生,不是本实现自己算的
|
||
if v := got["X-Device-Id"]; v != "707304999019651" {
|
||
t.Fatalf("X-Device-Id 应为按 uid 派生的 15 位数字 707304999019651,实得 %q", v)
|
||
}
|
||
})
|
||
t.Run("行3 客户端身份头齐全", func(t *testing.T) {
|
||
for k, want := range map[string]string{
|
||
"X-Market-Client-Id": "VSCode 1.107.1",
|
||
"X-User-Region": "CN",
|
||
"Package-Type": "stable_cn",
|
||
"X-Lgw-Req-Sdk-Type": "3",
|
||
"X-Lscbd-Platform": "windows",
|
||
"App-Version": "0.1.52",
|
||
} {
|
||
if v := got[k]; v != want {
|
||
t.Errorf("头 %s = %q,期望 %q", k, v, want)
|
||
}
|
||
}
|
||
if got["X-Market-User-Id"] == "" || len(got["X-Market-User-Id"]) != 36 {
|
||
t.Errorf("X-Market-User-Id 应是 UUID v4 形状,实得 %q", got["X-Market-User-Id"])
|
||
}
|
||
if len(got["Vscode-Sessionid"]) != 64 {
|
||
t.Errorf("Vscode-Sessionid 应是 64 位 hex,实得 %q", got["Vscode-Sessionid"])
|
||
}
|
||
if got["X-Tt-Trace-Id"] == "" || got["X-Request-Id"] == "" {
|
||
t.Error("每次请求都应有独立的 X-Tt-Trace-Id / X-Request-Id")
|
||
}
|
||
})
|
||
}
|