diff --git a/internal/core/channels_more_test.go b/internal/core/channels_more_test.go new file mode 100644 index 0000000..2793139 --- /dev/null +++ b/internal/core/channels_more_test.go @@ -0,0 +1,325 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。 +// 平台定义、决策表、端点逐字照抄参考(见 channels.lua 注释),测试照参考行为搬。 + +// ---- Cline(WorkOS 设备码 + register 两步) ---- + +// clineHarness 假上游:device code / authenticate / register 三端点。 +type clineHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + tok atomic.Value // 当前 authenticate 响应脚本 []scriptStep + step int32 +} + +func newClineHarness(t *testing.T) *clineHarness { + t.Helper() + h := &clineHarness{t: t} + h.tok.Store([]scriptStep{}) + h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/user_management/authorize/device": + fmt.Fprint(w, `{"device_code":"CD1","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) + case "/user_management/authenticate": + i := int(atomic.AddInt32(&h.step, 1)) - 1 + s := h.tok.Load().([]scriptStep) + if i >= len(s) { + w.WriteHeader(500) + fmt.Fprint(w, `{"error":"unexpected"}`) + return + } + if s[i].http != 0 && s[i].http != 200 { + w.WriteHeader(s[i].http) + } + fmt.Fprint(w, s[i].body) + case "/api/v1/auth/register": + // 校验请求体是驼峰 accessToken/refreshToken(参考 registerClineTokens) + b, _ := io.ReadAll(r.Body) + for _, want := range []string{`"accessToken":"WO-AT-1"`, `"refreshToken":"WO-RT-1"`} { + if !strings.Contains(string(b), want) { + t.Errorf("register 请求体缺 %s: %s", want, b) + } + } + fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-AT-1","refreshToken":"CLINE-RT-1","userInfo":{"clineUserId":"u1","email":"dev@example.com"}}}`) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, _ := readBundledChannels(t) + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "cline", + "device": map[string]interface{}{"code_url": h.ts.URL + "/user_management/authorize/device", "token_url": h.ts.URL + "/user_management/authenticate"}, + "register": map[string]interface{}{"url": h.ts.URL + "/api/v1/auth/register"}, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *clineHarness) begin() map[string]interface{} { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"cline"}`, + }) + if err != nil { + h.t.Fatal(err) + } + return out.JSON.(map[string]interface{}) +} +func (h *clineHarness) poll(id string) map[string]interface{} { + out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ + Method: "GET", Query: map[string]string{"id": id}, + }) + return out.JSON.(map[string]interface{}) +} + +// runWorkosTable 是 WorkOS(Cline)分支的行为契约,供变异测试杀 workos 变异体。 +// 决策表 5 行,跟 TestClineWorkOSBehavior 同源(这里只返回布尔,不报细节)。 +func (h *clineHarness) runWorkosTable() bool { + st := func(steps ...string) map[string]interface{} { + h.tok.Store(scriptSteps(steps...)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + return h.poll(sid) + } + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + // 行1: 非200+error=authorization_pending → 等 + if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { + return false + } + // 行2: 非200+error=slow_down → 等 + interval+1(基线 1) + h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + if j2 := h.poll(sid); j2["state"] != "pending" { + return false + } else if iv, _ := j2["interval"].(float64); iv != 2 { + return false + } + // 行3: error=access_denied → failed + if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" { + return false + } + // 行4: 成功(access+refresh)→ register 换 CLINE token → completed + if j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`); j["state"] != "completed" || j["account"] != "dev@example.com" { + return false + } + v, _, _ := h.ps.GetSecret("channels", "channels:cline") + if v != "CLINE-AT-1" { + return false + } + // 行5: 非200无error字段 → failed(不是死等) + if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" { + return false + } + return true +} + +// TestClineWorkOSBehavior:WorkOS 判据(error 字段)+ register 两步 + slow_down +1s。 +func TestClineWorkOSBehavior(t *testing.T) { + h := newClineHarness(t) + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + + st := func(steps ...string) map[string]interface{} { + h.tok.Store(scriptSteps(steps...)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + return h.poll(sid) + } + + t.Run("行1 error=authorization_pending_非200也继续等", func(t *testing.T) { + if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { + t.Fatalf("应 pending: %v", j) + } + }) + t.Run("行2 error=slow_down_退避1秒", func(t *testing.T) { + h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`)) + atomic.StoreInt32(&h.step, 0) + j := h.begin() + sid := j["session"].(map[string]interface{})["id"].(string) + j2 := h.poll(sid) + if j2["state"] != "pending" { + t.Fatalf("应 pending: %v", j2) + } + if iv, _ := j2["interval"].(float64); iv != 2 { // 基线 1 + slow_down_step 1 + t.Fatalf("WorkOS slow_down 应 interval+1s 得 2, 实得 %v", iv) + } + }) + t.Run("行3 error=access_denied_失败带拒绝", func(t *testing.T) { + if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" { + t.Fatalf("应 failed: %v", j) + } + }) + t.Run("行4 成功_access+refresh_再打register_凭据是CLINE-AT-1", func(t *testing.T) { + j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`) + if j["state"] != "completed" || j["account"] != "dev@example.com" { + t.Fatalf("应 completed+dev@example.com: %v", j) + } + v, _, _ := h.ps.GetSecret("channels", "channels:cline") + if v != "CLINE-AT-1" { + t.Fatalf("凭据应是 register 的 CLINE-AT-1, 实得 %q", v) + } + }) + t.Run("行5 非200无error字段_失败", func(t *testing.T) { + if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" { + t.Fatalf("应 failed: %v", j) + } + }) +} + +// ---- Buddy(CodeBuddy state 轮询) ---- + +type buddyHarness struct { + t *testing.T + ps *lua.Plugins + ts *httptest.Server + tokStep int32 + tokOk int32 + acctOk int32 +} + +func newBuddyHarness(t *testing.T) *buddyHarness { + t.Helper() + h := &buddyHarness{t: t, tokOk: 1, acctOk: 1} + 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 == "/v2/plugin/auth/state": + fmt.Fprint(w, `{"data":{"state":"ST-1","authUrl":"https://www.codebuddy.cn/login/?platform=ide&state=ST-1"}}`) + case r.Method == "GET" && r.URL.Path == "/v2/plugin/auth/token": + i := atomic.AddInt32(&h.tokStep, 1) + if atomic.LoadInt32(&h.tokOk) == 0 { + w.WriteHeader(200) + fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`) + return + } + if i == 1 { + fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`) + } else { + fmt.Fprint(w, `{"data":{"accessToken":"BD-AT-1","refreshToken":"BD-RT-1"}}`) + } + case r.Method == "GET" && r.URL.Path == "/v2/plugin/login/account": + if atomic.LoadInt32(&h.acctOk) == 0 { + w.WriteHeader(500) + fmt.Fprint(w, `{"code":1}`) + return + } + fmt.Fprint(w, `{"data":{"nickName":"buddy-dev"}}`) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(h.ts.Close) + + c := kernelTestCore(t) + h.ps = c.Plugins() + src, _ := readBundledChannels(t) + if err := h.ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "buddy", + "state": map[string]interface{}{"state_url": h.ts.URL + "/v2/plugin/auth/state", "token_url": h.ts.URL + "/v2/plugin/auth/token", "account_url": h.ts.URL + "/v2/plugin/login/account"}, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + return h +} + +func (h *buddyHarness) begin() (map[string]interface{}, string) { + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"buddy"}`, + }) + if err != nil { + h.t.Fatal(err) + } + j := out.JSON.(map[string]interface{}) + return j, j["session"].(map[string]interface{})["id"].(string) +} +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}, + }) + return out.JSON.(map[string]interface{}) +} + +// TestBuddyStatePollBehavior:state-poll 两拍(先 11217 后 token)+ 账号查询 + account 失败不阻塞。 +func TestBuddyStatePollBehavior(t *testing.T) { + h := newBuddyHarness(t) + _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) + + t.Run("行1 state申请给出authUrl", func(t *testing.T) { + j, _ := h.begin() + if j["url"] == "" { + t.Fatalf("应有 authUrl: %v", j) + } + }) + t.Run("行2 首拍11217仍pending_次拍token_completed", func(t *testing.T) { + atomic.StoreInt32(&h.tokStep, 0) + _, sid := h.begin() + if j := h.poll(sid); j["state"] != "pending" { + t.Fatalf("首拍应 pending: %v", j) + } + if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" { + t.Fatalf("次拍应 completed+buddy-dev: %v", j) + } + v, _, _ := h.ps.GetSecret("channels", "channels:buddy") + if v != "BD-AT-1" { + t.Fatalf("凭据应 BD-AT-1, 实得 %q", v) + } + }) + t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) { + atomic.StoreInt32(&h.tokStep, 0) + atomic.StoreInt32(&h.acctOk, 0) + defer atomic.StoreInt32(&h.acctOk, 1) + _, sid := h.begin() + h.poll(sid) + j := h.poll(sid) + if j["state"] != "completed" { + t.Fatalf("account 失败不应阻塞: %v", j) + } + }) +} + +// ---- 通用 ---- + +func scriptSteps(steps ...string) []scriptStep { + var out []scriptStep + for _, b := range steps { + code := 200 + switch { + case strings.HasPrefix(b, "400 "): + code, b = 400, b[4:] + case strings.HasPrefix(b, "403 "): + code, b = 403, b[4:] + case strings.HasPrefix(b, "500 "): + code, b = 500, b[4:] + } + out = append(out, scriptStep{http: code, body: b}) + } + return out +} diff --git a/internal/core/device_mutate_test.go b/internal/core/device_mutate_test.go index a6d088c..223e4d0 100644 --- a/internal/core/device_mutate_test.go +++ b/internal/core/device_mutate_test.go @@ -16,6 +16,8 @@ func TestDevicePollMutants(t *testing.T) { t.Fatal(err) } + // 锚点会命中 deviceOneStep 里两处同形代码(workos 分支 + minimax 分支), + // 全部替换:两个决策表(minimax + workos)都会杀,任一幸存即失败。 muts := []struct { name string from string @@ -23,7 +25,7 @@ func TestDevicePollMutants(t *testing.T) { }{ { name: "slow_down 不再加 interval", - from: `sess.device_interval = (sess.device_interval or d.interval or 5) + 5`, + from: `sess.device_interval = (sess.device_interval or d.interval or 5) + step`, to: `sess.device_interval = (sess.device_interval or d.interval or 5)`, }, { @@ -53,7 +55,9 @@ func TestDevicePollMutants(t *testing.T) { if !strings.Contains(src, mu.from) { t.Fatalf("锚点丢失,先更新实现与锚点:%q", mu.from) } - mutated := strings.Replace(src, mu.from, mu.to, 1) + // 锚点在 deviceOneStep 的 workos/minimax 两个分支各有一份同形代码, + // 全部替换:任一分支的变异体被任一决策表杀掉才算有覆盖。 + mutated := strings.ReplaceAll(src, mu.from, mu.to) if mutated == src { t.Fatal("变异未生效") } diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 33e2335..af35052 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -164,6 +164,69 @@ local PLATFORMS = { }, }, + -- Cline —— WorkOS 设备码流(照参考 cline-oauth.ts / cline-product.ts 逆向)。 + -- 与 MiniMax 同为 device-poll,但判据形态完全不同(参考注释反复强调): + -- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段; + -- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端); + -- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token + -- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。 + { + id = "cline", + name = "Cline", + vendor = "cline", + flow = "device-poll", + device = { + code_url = "https://api.workos.com/user_management/authorize/device", + code_body = "client_id=client_01K3A541FN8TA3EPPHTD2325AR", + token_url = "https://api.workos.com/user_management/authenticate", + token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" + .. "&device_code={device_code}&client_id=client_01K3A541FN8TA3EPPHTD2325AR", + interval = 5, + -- WorkOS 形态:判 error 字段;slow_down 退避 1 秒;access_denied/invalid_grant 同组 + judge = "workos", + slow_down_step = 1, + }, + -- 第二步:WorkOS token → Cline token(挂 apiBase) + register = { + url = "https://api.cline.bot/api/v1/auth/register", + method = "POST", + body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}', + }, + hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" }, + base_url = "https://api.cline.bot", + adapter = "openai", + -- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费) + models = { + "stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor", + }, + -- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取 + }, + + -- Buddy(腾讯 CodeBuddy)—— state 轮询流(照参考 buddy-oauth.ts 逆向)。 + -- 两步:POST /v2/plugin/auth/state → {state, authUrl};用户在 authUrl 完成登录后 + -- 插件轮询 GET /v2/plugin/auth/token?state=...。判据:业务码 11217 = 未就绪继续等; + -- 网络错误同样继续轮询不中断(对齐 Rust loop_get_token)。token 就绪后再拿账号。 + { + id = "buddy", + name = "CodeBuddy(腾讯)", + vendor = "tencent", + flow = "state-poll", + state = { + state_url = "https://copilot.tencent.com/v2/plugin/auth/state?platform=ide", + token_url = "https://copilot.tencent.com/v2/plugin/auth/token", + account_url = "https://copilot.tencent.com/v2/plugin/login/account", + code_not_ready = 11217, + interval = 1, + }, + hosts = { "copilot.tencent.com", "www.codebuddy.cn" }, + base_url = "https://copilot.tencent.com", + adapter = "openai", + models = { + "deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x", + "glm-5.3", "glm-5.3-flash", "glm-5.2", + }, + }, + -- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。 { id = "loopbackish", @@ -184,8 +247,179 @@ local PLATFORMS = { models = { "c-model-1" }, checkin = { url = "https://api.cookieish.example/v1/api/credit/claim", method = "POST", body = "{}" }, }, + + -- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。 + -- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调 + -- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。 + -- client_id 是 Cloud Code 的公开客户端(逆向自 binary),scope 六项逐字照抄。 + { + id = "gemini", + name = "Gemini(Cloud Code)", + vendor = "google", + flow = "callback", + login_url = "https://accounts.google.com/o/oauth2/v2/auth" + .. "?client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + .. "&redirect_uri=http://127.0.0.1:8845/auth/callback" + .. "&response_type=code" + .. "&scope=openid+email+profile+cloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email" + .. "&include_granted_scopes=true", + callback = { + mode = "code", + token_url = "https://oauth2.googleapis.com/token", + token_body = "grant_type=authorization_code&code={code}" + .. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + .. "&redirect_uri=http://127.0.0.1:8845/auth/callback", + pkce = true, -- 授权页带 verifier(S256),交换时回传 + }, + hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" }, + base_url = "https://cloudcode-pa.googleapis.com", + adapter = "gemini", + models = { "gemini-3.1-flash-lite-preview", "gemini-3.1-flash-preview" }, + }, + + -- LobsterAI(有道龙虾)—— 本地回调 + authCode 换 token(照参考 lobsterai-oauth.ts)。 + { + id = "lobsterai", + name = "LobsterAI(有道)", + vendor = "youdao", + flow = "callback", + login_url = "https://lobsterai.youdao.com/login", + callback = { + mode = "code", + token_url = "https://lobsterai-server.youdao.com/api/auth/login", + token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"cli","uuid":"","version":"1.0"}', + }, + hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com" }, + base_url = "https://lobsterai-server.youdao.com", + adapter = "openai", + models = { "kimi-k3", "glm-5.2", "deepseek-v4-flash" }, + }, + + -- OpenCode —— 直接粘贴 API key(无登录流,参考 opencode-auth.ts)。 + { + id = "opencode", + name = "OpenCode Zen", + vendor = "opencode", + flow = "paste", + base_url = "https://opencode.ai/zen", + adapter = "openai", + models = { "big-pickle", "claude-sonnet-4-20250514", "gpt-5.1-codex-mini" }, + }, + + -- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。 + -- 登录是常规企业凭据(paste);签到走 snap-access 的 SDK-HMAC-SHA256 签名, + -- 放在 claim_fn 钩子里(checkin 表驱动驱动不动签名请求,参考注释也点明这一点)。 + { + id = "codearts", + name = "CodeArts(华为)", + vendor = "huawei", + flow = "paste", + base_url = "https://snap-access.cn-north-4.myhuaweicloud.com", + adapter = "openai", + models = { "CodeArts-Pangu" }, + checkin = { + url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin", + method = "GET", + }, + -- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾) + claim_fn_name = "claimCodeArts", + }, + + -- Qoder —— PKCE 设备码 + WASM 加密签名(照参考 qoder-oauth.ts 逆向)。 + -- WASM 签名头无法在纯 Lua 环境完成,登录标 manual 给明确说明。 + { + id = "qoder", + name = "Qoder", + vendor = "qoder", + flow = "manual", + manual_hint = "Qoder 登录要 WASM 签名头,纯 Lua 环境无法生成。请在浏览器完成" + .. "授权后,把最终凭证粘贴进来(手动输入凭据)。", + base_url = "https://api2-v2.qoder.sh", + adapter = "openai", + models = { "qoder-default" }, + }, + + -- ZCode —— cli/init + captcha(照参考 zcode-auth.ts 逆向)。 + -- captcha 求解(mint captcha + 补激活上报)无法在纯 Lua 完成,标 manual。 + { + 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", + models = { "zcode-default" }, + }, + + -- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。 + -- 扫码要在微信 App 里完成,纯 Lua 环境拿不到微信凭据,标 manual。 + { + id = "loomy", + name = "Loomy", + vendor = "loomy", + flow = "manual", + manual_hint = "Loomy 登录要微信扫码(在微信公众号里完成授权)。请在微信里" + .. "扫码后,把拿到的凭证粘贴进来。", + base_url = "https://loomy.example.com/v1", + adapter = "openai", + models = { "loomy-default" }, + }, + + -- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。 + -- 扫码是图片码,纯 Lua 环境无法展示与回取,标 manual。 + { + id = "raccoon", + name = "Raccoon(商汤小浣熊)", + vendor = "sensetime", + flow = "manual", + manual_hint = "Raccoon 登录要扫图片二维码(商汤小浣熊官网)。请扫码后," + .. "把 login_with_qrcode_code 拿到的凭证粘贴进来。", + base_url = "https://raccoon.sensetime.com", + adapter = "openai", + models = { "raccoon-default" }, + }, } +-- CodeArts 的 SDK-HMAC-SHA256 签到钩子(照参考 codearts-credits.ts 逆向)。 +-- 绑在 PLATFORMS 表定义之后:claim_daily 读取 plat.claim_fn,checkin 表驱动 +-- 不动签名请求(参考注释也点明这一点),所以用钩子生成签名后的请求形状。 +do + local ca + for _, p in ipairs(PLATFORMS) do + if p.id == "codearts" then ca = p break end + end + if ca then + -- SDK-HMAC-SHA256 派生签名密钥链(照参考 canonical request 形状)。 + ca.claim_fn = function(plat, tok, args) + local url = plat.checkin.url + local method = plat.checkin.method or "GET" + local host = url:match("^https?://([^/:]+)") + local path = url:match("^https?://[^/]+(/[^?]*)") + local ts = tostring(os.time and os.time() or 0) + local nonce = tostring(math.random(1e12)) + local canonical = method .. "\n" .. (path or "/") .. "\n" .. ts .. "\n" .. nonce + local secret = tok or "" + local kDate = hmac_sha256_hex("SDK" .. secret, ts) + local kNonce = hmac_sha256_hex(kDate, nonce) + local kSigning = hmac_sha256_hex(kNonce, "sdk_request") + local signature = hmac_sha256_hex(kSigning, canonical) + return { + url = url, + method = method, + headers = { + ["Host"] = host, + ["X-Sdk-Date"] = ts, + ["Authorization"] = "SDK-HMAC-SHA256 Credential=" + .. (args and args.access_key or "") .. "/" .. ts .. "/" .. nonce + .. "/sdk_request, SignedHeaders=host, Signature=" .. signature, + }, + } + end + end +end + -- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。 -- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。 local function declaredHosts() @@ -285,13 +519,44 @@ local function deviceOneStep(plat, sess) if err then return false end -- 网络抖动不算失败:与参考一致(pending 语义) local ok, rec = pcall(json.decode, resp.body or "") if not ok or type(rec) ~= "table" then rec = {} end + + -- 判据形态分两套(这正是 MiniMax 与 Cline 的核心差异,参考注释反复强调): + -- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。 + -- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx); + -- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端); + -- access_denied / expired_token / invalid_grant 同组判失败。 + -- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。 + local workos = (d.judge == "workos") local status = rec.status local ecode = rec.error + local step = tonumber(d.slow_down_step) or 5 - -- 形态一:HTTP 200 + status + if workos then + if ecode == "authorization_pending" then return false end + if ecode == "slow_down" then + sess.device_interval = (sess.device_interval or d.interval or 5) + step + return false + end + if ecode == "access_denied" or ecode == "expired_token" or ecode == "invalid_grant" then + local msg = rec.error_description or (ecode == "access_denied" and "用户拒绝了授权" or "授权已过期") + return true, { ok = false, reason = (ecode == "access_denied" and "denied" or "expired"), message = msg } + end + if resp.status == 200 and rec.access_token and rec.refresh_token then + return true, { ok = true, access_token = rec.access_token, refresh_token = rec.refresh_token, + need_register = true, account = "cline" } + end + if resp.status ~= 200 and next(rec) ~= nil then + return true, { ok = false, reason = "error", + message = "WorkOS token 轮询失败(HTTP " .. tostring(resp.status) .. ")" + .. (rec.error_description and (" - " .. rec.error_description) or "") } + end + return false -- 2xx 但既无 error 又无 token:等下一拍(避免误报死循环) + end + + -- MiniMax 形态(原样保留,已由决策表测试钉死) if resp.status == 200 and status == "pending" then return false end if resp.status == 200 and status == "slow_down" then - sess.device_interval = (sess.device_interval or d.interval or 5) + 5 + sess.device_interval = (sess.device_interval or d.interval or 5) + step return false end if resp.status == 200 and (status == "denied" or status == "access_denied") then @@ -300,10 +565,9 @@ local function deviceOneStep(plat, sess) if resp.status == 200 and (status == "expired" or status == "expired_token") then return true, { ok = false, reason = "expired", message = "设备码已过期,请重新发起登录" } end - -- 形态二:非 200 + error(OAuth 标准形态) if ecode == "authorization_pending" then return false end if ecode == "slow_down" then - sess.device_interval = (sess.device_interval or d.interval or 5) + 5 + sess.device_interval = (sess.device_interval or d.interval or 5) + step return false end if resp.status == 200 and rec.access_token then @@ -313,6 +577,42 @@ local function deviceOneStep(plat, sess) return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) } end +-- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。 +-- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式 +-- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。 +local function deviceRegister(plat, payload) + local r = plat.register + if not r or not r.url then return payload.access_token, payload.account, nil end + local body = (r.body or "") + :gsub("{access_token}", payload.access_token or "") + :gsub("{refresh_token}", payload.refresh_token or "") + local resp, err = __llmsproxy_http({ + url = r.url, + method = r.method or "POST", + headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }, + body = body, + }) + if err then return nil, nil, "token 注册失败: " .. err end + if resp.status ~= 200 then + return nil, nil, "token 注册失败(HTTP " .. tostring(resp.status) .. ")" + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then + return nil, nil, "token 注册响应无法解析" + end + -- 响应套 {success, data} 信封(参考 parseClineTokenPayload) + local data = rec.data or rec + local access = data.accessToken or data.access_token or data.Token or data.token + if not access or access == "" then + return nil, nil, "token 注册响应缺少 accessToken" + end + local acct = "cline" + if type(data.userInfo) == "table" and (data.userInfo.email or data.userInfo.clineUserId) then + acct = tostring(data.userInfo.email or data.userInfo.clineUserId) + end + return access, acct, nil +end + -- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表) local function platformById(id) for _, p in ipairs(PLATFORMS) do @@ -480,7 +780,38 @@ function plugin.start_login(req) state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at }, platform = plat.id, } - if plat.flow == "vnc" then + if plat.flow == "manual" then + -- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。 + sess.state = "failed" + sess.message = plat.manual_hint or "该平台不支持自动登录" + out.manual_hint = sess.message + out.url = plat.login_url + logEvent("手动流程 " .. plat.id) + return { status = 200, json = out } + elseif plat.flow == "state-poll" then + -- Buddy:先拿 state + authUrl。页面给 authUrl,轮询 token 每推进一步。 + local resp, err = __llmsproxy_http({ + url = plat.state.state_url, + method = "POST", + headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/json" }, + body = "{\"platform\":\"ide\"}", + }) + if err or resp.status ~= 200 then + sess.state = "failed" + out.error = "auth/state 失败: " .. (err or ("HTTP " .. tostring(resp.status))) + return { status = 200, json = out } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or type(rec.data) ~= "table" + or not rec.data.state or rec.data.state == "" then + sess.state = "failed" + out.error = "auth/state 响应缺少 state 字段" + return { status = 200, json = out } + end + sess.state_token = rec.data.state + out.url = rec.data.authUrl or plat.login_url + logEvent("state 已申请 " .. plat.id) + elseif plat.flow == "vnc" then -- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。 local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/http/channels/callback") if berr then @@ -550,20 +881,74 @@ function plugin.poll_login(req) local done, payload = deviceOneStep(plat, s) if done and payload then if payload.ok then - -- 成功:落凭据 + 接入源 - local res = plugin.complete_login({ platform = s.platform, credential = payload.access_token, account = payload.account }) - if res.json and res.json.ok then - s.state = "completed" - s.account = payload.account - else + -- 设备码到 token 后,有的平台还要第二跳(Cline register)换最终凭据 + local cred, acct, rerr = deviceRegister(plat, payload) + if rerr then s.state = "failed" - s.message = (res.json and res.json.error) or "接入源失败" + s.message = rerr + else + local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account }) + if res.json and res.json.ok then + s.state = "completed" + s.account = acct or payload.account + else + s.state = "failed" + s.message = (res.json and res.json.error) or "接入源失败" + end end else s.state = "failed" s.message = payload.message 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) + local sp = plat and plat.state + local resp, err = __llmsproxy_http({ + url = (sp.token_url or "") .. "?state=" .. tostring(s.state_token), + method = "GET", + headers = { ["Accept"] = "application/json", ["X-No-Authorization"] = "true" }, + }) + -- 网络错误:继续轮询不中断(对齐参考 loopGetToken 的 catch 继续) + if not err and resp then + local ok, rec = pcall(json.decode, resp.body or "") + if ok and type(rec) == "table" then + 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" + local aresp, aerr = __llmsproxy_http({ + url = (sp.account_url or "") .. "?state=" .. tostring(s.state_token), + method = "GET", + headers = { ["Accept"] = "application/json", ["Authorization"] = "Bearer " .. tok }, + }) + 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) + end + end + local res = plugin.complete_login({ platform = s.platform, credential = tok, account = acct }) + 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 + elseif code == sp.code_not_ready then + -- 11217 = 未就绪,继续轮询(对齐参考:业务码 11217 继续等) + elseif resp.status == 200 and data == nil then + -- data 为 null,继续轮询(参考明确此分支) + else + s.state = "failed" + s.message = "auth/token HTTP " .. tostring(resp.status) .. " code=" .. tostring(code) + end + end + end end return { status = 200, json = { state = s.state, account = s.account, message = s.message, interval = s.device_interval } } end @@ -737,6 +1122,16 @@ function plugin.set_endpoints(req) if args.device[k] then plat.device[k] = tostring(args.device[k]) changed[#changed+1] = "device." .. k end end end + if type(args.register) == "table" and plat.register then + for _, k in ipairs({ "url" }) do + if args.register[k] then plat.register[k] = tostring(args.register[k]) changed[#changed+1] = "register." .. k end + end + end + if type(args.state) == "table" and plat.state then + for _, k in ipairs({ "state_url", "token_url", "account_url" }) do + if args.state[k] then plat.state[k] = tostring(args.state[k]) changed[#changed+1] = "state." .. k end + end + end if type(args.checkin) == "table" and plat.checkin then for _, k in ipairs({ "url" }) do if args.checkin[k] then plat.checkin[k] = tostring(args.checkin[k]) changed[#changed+1] = "checkin." .. k end @@ -803,11 +1198,41 @@ function plugin.oauth_callback(req) return { status = 400, body = "session is not a callback flow" } end local plat = platformById(s.platform) - local mode = plat and plat.callback and plat.callback.mode or "code" + local cb = plat and plat.callback or {} + local mode = cb.mode or "code" local credential, account, cerr if mode == "trae" then credential, account, cerr = traeCallback(plat, req.query) + elseif mode == "code" and cb.token_url then + -- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。 + local code = req.query.code + if not code or code == "" then + cerr = "missing code" + else + local body = (cb.token_body or "") + :gsub("{code}", code) + :gsub("{verifier}", s.device_verifier or "") + local resp, err = __llmsproxy_http({ + url = cb.token_url, + method = "POST", + headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Accept"] = "application/json" }, + body = body, + }) + if err then + cerr = "token 交换失败: " .. err + elseif resp.status ~= 200 then + cerr = "token 交换失败(HTTP " .. tostring(resp.status) .. ")" + else + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or not rec.access_token then + cerr = "token 交换响应缺少 access_token" + else + credential = rec.access_token + account = "oauth" + end + end + end else credential = req.query.code account = "oauth" @@ -977,8 +1402,25 @@ plugin.ui = { "
请在下方窗口完成登录…
"; vnc.style.display = "block"; q("#ch-vnc").src = j.view_url; + } else if (j.manual_hint) { + // 无法自动化的登录(WASM/captcha/扫码):给说明 + 凭据粘贴框。 + // 后端 start_login 直接把会话标 failed,这里不再轮询。 + vnc.style.display = "none"; + q("#ch-login-body").innerHTML = + "" + esc(j.manual_hint) + "
" + + "" + + "" + + ""; + } else if (j.session && j.session.flow === "paste") { + // 直接粘贴 API key/token,无登录流(OpenCode 等)。 + vnc.style.display = "none"; + q("#ch-login-body").innerHTML = + "把该平台的 API key / token 粘贴到这里:
" + + "" + + "" + + ""; } else { - // 用户自己的浏览器足够(link-poll / callback / paste) + // 用户自己的浏览器足够(link-poll / callback / device-poll) vnc.style.display = "none"; q("#ch-login-body").innerHTML = "请在你自己的浏览器打开下面的地址完成登录:
" + @@ -986,15 +1428,31 @@ plugin.ui = { (j.session && j.session.user_code ? "设备码:" + esc(j.session.user_code) + "
" : "") + "等待完成…
"; } - stopPoll(); - pollTimer = setInterval(async () => { - const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id)); - const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state; - if (st.state === "completed" || st.state === "failed" || st.state === "expired") { - stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none"; - box.style.display = "none"; refresh(); - } - }, 2000); + if (!(j.manual_hint || (j.session && j.session.flow === "paste"))) { + stopPoll(); + pollTimer = setInterval(async () => { + const st = await call("channels/login/status?id=" + encodeURIComponent(j.session.id)); + const el = q("#ch-login-state"); if (el) el.textContent = "状态:" + st.state; + if (st.state === "completed" || st.state === "failed" || st.state === "expired") { + stopPoll(); q("#ch-vnc").src = "about:blank"; vnc.style.display = "none"; + box.style.display = "none"; refresh(); + } + }, 2000); + } + }; + + // chComplete:paste / manual 流的凭据提交。凭据只经 complete_login 落保险库, + // 不经 state、不回显。失败时给可读错误而不刷新页面(用户可重试粘贴)。 + window.chComplete = async function (id) { + const cred = (q("#ch-cred") && q("#ch-cred").value || "").trim(); + if (!cred) { alert("凭据为空"); return; } + const el = q("#ch-login-state"); if (el) el.textContent = "提交中…"; + const j = await call("channels/login/complete", "POST", { platform: id, credential: cred }); + if (j.error) { if (el) el.textContent = "失败:" + j.error; return; } + if (j.source_error) { if (el) el.textContent = "凭据已存,但接入源失败:" + j.source_error; refresh(); return; } + q("#ch-login-box").style.display = "none"; + q("#ch-cred").value = ""; + refresh(); }; window.chClaim = async function (id) {