diff --git a/internal/core/channels_qoder_machine_test.go b/internal/core/channels_qoder_machine_test.go new file mode 100644 index 0000000..03d1166 --- /dev/null +++ b/internal/core/channels_qoder_machine_test.go @@ -0,0 +1,84 @@ +package core + +import ( + "os" + "path/filepath" + "testing" +) + +// Qoder 的设备身份两个头**必须成对**出现:参考用抓包 + 消融实验定位过 —— +// 缺任一个,服务端就不下发「可领取」的活动(只回 VIEW_DETAILS), +// 症状是用户报的「没领过却显示今日已领取」。 +// 同时:拿不到身份时必须**保守降级**(照常发请求、不带这两个头), +// 不能让纯插件登录(未装桌面端)的账号整个积分功能报错。 +func TestQoderMachineIdentityPairingAndDegradation(t *testing.T) { + t.Run("行1 有 machine_token.json → 成对带上两个头", func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "machine_token.json") + if err := os.WriteFile(p, []byte(`{"token":"T-abc","type":"TY-def","updateAt":1}`), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("QODER_MACHINE_TOKEN_PATH", p) + + 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) + } + got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("probe: %v", err) + } + w, _ := got.(map[string]interface{}) + m, _ := w["json"].(map[string]interface{}) + if m == nil { + t.Fatalf("无 json: %#v", got) + } + if m["token"] != "T-abc" || m["type"] != "TY-def" { + t.Fatalf("应从磁盘缓存读出 token/type,实得 %v", m) + } + if m["client_type"] != "10" { + t.Fatalf("Cosy-ClientType 应为 10(参考实测值),实得 %v", m["client_type"]) + } + }) + + t.Run("行2 无任何来源 → 降级:不带这两个头,且不报错", func(t *testing.T) { + // 指向不存在的路径,且把 HOME 指到空目录(找不到 runtime-info) + t.Setenv("QODER_MACHINE_TOKEN_PATH", filepath.Join(t.TempDir(), "nope.json")) + t.Setenv("HOME", t.TempDir()) + t.Setenv("APPDATA", "") + + 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) + } + got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("probe 不应报错(必须保守降级): %v", err) + } + w, _ := got.(map[string]interface{}) + m, _ := w["json"].(map[string]interface{}) + if m == nil { + t.Fatalf("无 json: %#v", got) + } + if m["token"] != nil && m["token"] != "" { + t.Errorf("拿不到身份时不应带 token,实得 %v", m["token"]) + } + if m["type"] != nil && m["type"] != "" { + t.Errorf("拿不到身份时不应带 type,实得 %v", m["type"]) + } + // 头仍然要有 clienttype(那是必要但不充分条件,与身份无关) + if m["client_type"] != "10" { + t.Errorf("降级时仍应带 Cosy-ClientType=10,实得 %v", m["client_type"]) + } + }) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index bfa931c..99bba9c 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe" } @@ -76,6 +76,11 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda -- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。 -- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua -- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。 +-- 前向声明:qoder 平台表里的 headers_fn 要在运行时调用它,而真实定义在 +-- 文件后段。Lua 的 local 作用域看不到后面的定义 —— 必须在**使用点之前**声明 +-- (此处就是 PLATFORMS 表之前),否则 headers_fn 里拿到 nil(真实踩过)。 +local qoderMachineIdentity + local PLATFORMS = { -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== -- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 @@ -625,6 +630,24 @@ local PLATFORMS = { }, -- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining -- + addOnQuota.remaining,走 openApiBase) + -- 身份头:cosy-clienttype 是必要但**不充分**条件,服务端还校验设备身份 + -- (见 qoderMachineIdentity 的函数注释:缺 Cosy-MachineToken/MachineType + -- 时不下发可领取的活动)。拿不到身份时照常发(保守降级)。 + headers_fn = function(accessToken, cred) + local h = { + ["Authorization"] = "Bearer " .. accessToken, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["Cosy-ClientType"] = "10", + } + local id = qoderMachineIdentity() + if id then + -- 必须成对出现(消融表:缺一即失效) + h["Cosy-MachineToken"] = id.token + h["Cosy-MachineType"] = id.type + end + return h + end, balance = { url = "https://openapi.qoder.sh/sash/api/v2/me/usage", method = "GET", @@ -994,6 +1017,130 @@ end -- actions 不含 check_in → inactive -- ③ POST .../{activityCode}/actions/check_in(带客户端幂等键)→ 成功 -- 参考注释特别强调:无可用活动是**正常业务状态**,不能显示成"领取失败"。 +-- ---- Qoder 设备身份(Cosy-MachineToken / Cosy-MachineType)---- +-- +-- 逐行对齐参考 qoder-machine.ts。参考用抓包 + 消融实验定位过:**缺这对头时 +-- 服务端不下发「可领取」的活动**(只回 1 条 VIEW_DETAILS 而非 +-- CLAIM_BENEFIT/CLAIMABLE/amount:100),症状正是用户报的 +-- 「没领过却显示今日已领取,去 IDE 看还能领」。要点: +-- 1. 两值**必须成对**出现,缺一即失效(消融表:全套 − 任一个 → 退回 1 条)。 +-- 2. 主路径是实时调 `runtime-info`:参数顺序**必须是 `[environment, '--account-stdin']`**, +-- environment 取 '3'(对应 asar 的 'global' 分支)。漏环境值会拿到另一套身份。 +-- stdin 传 `{"account":""}`,输出是含 `{` 的单行 JSON(取 machineToken/machineType)。 +-- 3. 退路是磁盘缓存 `machine_token.json`(token/type 字段)。 +-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级, +-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。 +-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。 +local QODER_MACHINE_ENVIRONMENT = "3" + +local function qoderMachineCache() + plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false } + return plugin.state.qoderMachine +end + +-- 读单个 machine_token.json(形状不符/读取失败返回 nil)。 +local function qoderParseMachineFile(path) + local f = io.open(path, "r") + if not f then return nil end + local raw = f:read("*a") + f:close() + local ok, rec = pcall(json.decode, raw or "") + if not ok or type(rec) ~= "table" then return nil end + local token, typ = rec.token, rec.type + if type(token) ~= "string" or token == "" then return nil end + if type(typ) ~= "string" or typ == "" then return nil end + return { token = token, type = typ } +end + +-- 候选路径(照参考 candidatePaths:env 覆盖 → APPDATA → macOS → Linux) +local function qoderMachineCandidatePaths() + local out = {} + local override = os.getenv and os.getenv("QODER_MACHINE_TOKEN_PATH") + if type(override) == "string" and override ~= "" then return { override } end + local appdata = os.getenv and os.getenv("APPDATA") + if type(appdata) == "string" and appdata ~= "" then + for _, d in ipairs({ "Qoder", "Qoder CN" }) do + out[#out + 1] = appdata .. "/" .. d .. "/SharedClientCache/cache/machine_token.json" + end + end + local home = os.getenv and (os.getenv("HOME") or os.getenv("USERPROFILE")) or nil + if type(home) == "string" and home ~= "" then + for _, d in ipairs({ "Qoder", "Qoder CN" }) do + out[#out + 1] = home .. "/Library/Application Support/" .. d + .. "/SharedClientCache/cache/machine_token.json" + out[#out + 1] = home .. "/.config/" .. d .. "/SharedClientCache/cache/machine_token.json" + end + end + return out +end + +-- 定位 runtime-info://.bin/umid-*/runtime-info(目录名带哈希,必须枚举) +local function qoderFindRuntimeInfo() + if not (os.getenv and os.getenv("HOME")) then return nil end + local exeName = "runtime-info" + for _, dirName in ipairs({ ".qoder", ".qoder-cn" }) do + local binDir = os.getenv("HOME") .. "/" .. dirName .. "/.bin" + local h = io.popen("ls -1 " .. binDir .. " 2>/dev/null") + if h then + local found + for line in h:lines() do + if line:sub(1, 5) == "umid-" then found = line break end + end + h:close() + if found then + local exe = binDir .. "/" .. found .. "/" .. exeName + local f = io.open(exe, "rb") + if f then f:close() return exe end + end + end + end + return nil +end + +-- 实时生成(照参考:参数顺序 [env, '--account-stdin'],stdin 传 {"account":""}) +local function qoderReadFromRuntimeInfo() + local exe = qoderFindRuntimeInfo() + if not exe then return nil end + -- 用临时文件喂 stdin:命令行传 JSON 的引号在 shell 下不可靠 + local tmp = "/tmp/llmsproxy-qoder-stdin-" .. tostring(math.random(1e12)) + local f = io.open(tmp, "w") + if not f then return nil end + f:write('{"account":""}') + f:close() + local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format( + exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp) + local h = io.popen(cmd) + local out = h and h:read("*a") or "" + if h then h:close() end + os.remove(tmp) + if out == "" then return nil end + -- 输出形如(单行 JSON):取第一个 '{' 起 + local i = out:find("{", 1, true) + if not i then return nil end + local ok, rec = pcall(json.decode, out:sub(i)) + if not ok or type(rec) ~= "table" then return nil end + local token, typ = rec.machineToken, rec.machineType + if type(token) ~= "string" or token == "" then return nil end + if type(typ) ~= "string" or typ == "" then return nil end + return { token = token, type = typ } +end + +-- qoderMachineIdentity():返回 {token,type} 或 nil(拿不到时降级,不报错)。 +qoderMachineIdentity = function() + local c = qoderMachineCache() + if c.done then return c.identity end + local id = qoderReadFromRuntimeInfo() + if not id then + for _, path in ipairs(qoderMachineCandidatePaths()) do + id = qoderParseMachineFile(path) + if id then break end + end + end + c.done = true + c.identity = id + return id +end + -- 前向声明:本段(活动领取)要用到的这几个助手都定义在文件后段。 -- Lua 的 local 作用域**看不到后面的定义**,不声明就是 -- "attempt to call global 'xxx' (a nil value)"(本文件已在这上面踩过多次: @@ -5646,6 +5793,19 @@ do end end +-- plugin.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径, +-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。 +function plugin.__qoder_machine_probe(args) + local ph = nil + for _, p in ipairs(PLATFORMS) do + if p.id == "qoder" then ph = p break end + end + if not ph or not ph.headers_fn then return { json = { error = "qoder headers_fn 缺失" } } end + local h = ph.headers_fn("TOK", {}) + return { json = { token = h["Cosy-MachineToken"], type = h["Cosy-MachineType"], + client_type = h["Cosy-ClientType"] } } +end + -- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。 -- 允许传入 date 以对齐参考的固定时间戳。 function plugin.__codearts_sign_probe(args)