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,断言同步更新
210 lines
8.1 KiB
Go
210 lines
8.1 KiB
Go
package e2e
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端:
|
||
//
|
||
// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据)
|
||
// paste(opencode:直接 complete_login 贴 API key)
|
||
// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成)
|
||
//
|
||
// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。
|
||
func TestChannelsFlowsE2E(t *testing.T) {
|
||
var authHits, regHits int32
|
||
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
switch r.URL.Path {
|
||
// ---- Cline(WorkOS)----
|
||
case "/user_management/authorize/device":
|
||
fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
|
||
case "/user_management/authenticate":
|
||
if atomic.AddInt32(&authHits, 1) == 1 {
|
||
w.WriteHeader(400)
|
||
fmt.Fprint(w, `{"error":"authorization_pending"}`)
|
||
return
|
||
}
|
||
fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`)
|
||
case "/api/v1/auth/register":
|
||
atomic.AddInt32(®Hits, 1)
|
||
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`)
|
||
// ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)----
|
||
case "/v1/models":
|
||
fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qmodel_38max","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
|
||
case "/v1/chat/completions":
|
||
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
|
||
default:
|
||
w.WriteHeader(404)
|
||
}
|
||
}))
|
||
defer plat.Close()
|
||
|
||
dir := t.TempDir()
|
||
addr := mustAddr(t)
|
||
pluginDir := filepath.Join(dir, "plugins")
|
||
cfg := "listen: " + addr + "\n" +
|
||
"gateway_keys:\n - sk-e2e-0001\n" +
|
||
"default_model: none\n" +
|
||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||
"plugin_dir: " + pluginDir + "\n" +
|
||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||
"sources: []\n"
|
||
cfgPath := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
bin := buildBinary(t)
|
||
g := startGatewayNoModels(t, bin, addr, cfgPath)
|
||
|
||
call := func(method, path, body string) (int, map[string]interface{}) {
|
||
resp, raw := g.do(method, path, body, true)
|
||
var m map[string]interface{}
|
||
_ = json.Unmarshal([]byte(raw), &m)
|
||
return statusOf(resp), m
|
||
}
|
||
|
||
st, m := call("PUT", "/api/plugins/channels/kernel",
|
||
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
|
||
if st != 200 {
|
||
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
|
||
}
|
||
|
||
// ---------- ① Cline:WorkOS 设备码两步 ----------
|
||
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
|
||
`{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`,
|
||
plat.URL, plat.URL+"/user_management/authorize/device",
|
||
plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register"))
|
||
if st != 200 {
|
||
t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m)
|
||
}
|
||
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`)
|
||
if st != 200 {
|
||
t.Fatalf("cline start_login status=%d body=%v", st, m)
|
||
}
|
||
sess, _ := m["session"].(map[string]interface{})
|
||
if sess == nil || sess["user_code"] != "CLNE-1234" {
|
||
t.Fatalf("cline start_login 未透传 user_code: %v", m)
|
||
}
|
||
sid, _ := sess["id"].(string)
|
||
|
||
// 第一拍 pending(authorization_pending),第二拍 completed
|
||
atomic.StoreInt32(&authHits, 0)
|
||
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
|
||
if m["state"] != "pending" {
|
||
t.Fatalf("cline 轮询1 应 pending, 实得 %v", m)
|
||
}
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
|
||
if m["state"] == "completed" || m["state"] == "failed" {
|
||
break
|
||
}
|
||
time.Sleep(150 * time.Millisecond)
|
||
}
|
||
if m["state"] != "completed" {
|
||
t.Fatalf("cline 轮询2 应 completed, 实得 %v", m)
|
||
}
|
||
if m["account"] != "dev@example.com" {
|
||
t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m)
|
||
}
|
||
if atomic.LoadInt32(®Hits) != 1 {
|
||
t.Fatalf("register 应恰好调用一次, 实得 %d", regHits)
|
||
}
|
||
// 凭据必须是 register 的最终 token,不是 WorkOS token
|
||
var modelsBody string
|
||
deadline = time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
resp, raw := g.do("GET", "/v1/models", "", true)
|
||
modelsBody = raw
|
||
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "stealth/space-bunny-alpha") {
|
||
break
|
||
}
|
||
time.Sleep(150 * time.Millisecond)
|
||
}
|
||
if !strings.Contains(modelsBody, "stealth/space-bunny-alpha") {
|
||
t.Fatalf("cline 接入后 /v1/models 未出现其模型: %s", modelsBody)
|
||
}
|
||
|
||
// ---------- ② paste(opencode):complete 直接贴 key ----------
|
||
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
|
||
`{"platform":"opencode","base_url":%q}`, plat.URL))
|
||
if st != 200 {
|
||
t.Fatalf("set_endpoints(opencode) 失败: %v", m)
|
||
}
|
||
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"opencode"}`)
|
||
if st != 200 {
|
||
t.Fatalf("opencode start_login status=%d", st)
|
||
}
|
||
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "paste" {
|
||
t.Fatalf("opencode 会话 flow 应 paste: %v", m)
|
||
}
|
||
st, m = call("POST", "/api/plugins/http/channels/login/complete",
|
||
`{"platform":"opencode","credential":"OC-E2E-KEY"}`)
|
||
if st != 200 || m["ok"] != true {
|
||
t.Fatalf("opencode complete 失败: status=%d body=%v", st, m)
|
||
}
|
||
deadline = time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
resp, raw := g.do("GET", "/v1/models", "", true)
|
||
modelsBody = raw
|
||
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "big-pickle") {
|
||
break
|
||
}
|
||
time.Sleep(150 * time.Millisecond)
|
||
}
|
||
if !strings.Contains(modelsBody, "big-pickle") {
|
||
t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody)
|
||
}
|
||
|
||
// ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ----------
|
||
// qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程
|
||
// (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。
|
||
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
|
||
`{"platform":"qoder","base_url":%q}`, plat.URL))
|
||
if st != 200 {
|
||
t.Fatalf("set_endpoints(qoder) 失败: %v", m)
|
||
}
|
||
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`)
|
||
if st != 200 {
|
||
t.Fatalf("qoder start_login status=%d", st)
|
||
}
|
||
// 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint),
|
||
// 且会话声明为 device-poll。
|
||
if hint, _ := m["manual_hint"].(string); hint != "" {
|
||
t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint)
|
||
}
|
||
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" {
|
||
t.Fatalf("qoder 会话 flow 应 device-poll: %v", m)
|
||
}
|
||
if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") {
|
||
t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL)
|
||
}
|
||
st, m = call("POST", "/api/plugins/http/channels/login/complete",
|
||
`{"platform":"qoder","credential":"QD-E2E-KEY"}`)
|
||
if st != 200 || m["ok"] != true {
|
||
t.Fatalf("qoder complete 失败: status=%d body=%v", st, m)
|
||
}
|
||
deadline = time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
resp, raw := g.do("GET", "/v1/models", "", true)
|
||
modelsBody = raw
|
||
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") {
|
||
break
|
||
}
|
||
time.Sleep(150 * time.Millisecond)
|
||
}
|
||
if !strings.Contains(modelsBody, "qmodel_38max") {
|
||
t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody)
|
||
}
|
||
}
|