mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): Qoder 设备身份(Cosy-MachineToken/Type)—— 活动可见性的前提
## 参考依据
`qoder-machine.ts`。参考用**抓包 + 消融实验**定位过一个真实缺陷:用户报
「Qoder 没领过积分却显示『今日已领取』,去 IDE 看还能领」。
根因:`/sash/` 端点不只要 `Cosy-ClientType`,还校验**设备身份**。消融表(同账号、只改头):
| 请求头 | /sash/api/v1/me/campaigns |
|---|---|
| 仅 Cosy-ClientType: 10 | `claimable:false`,1 条 VIEW_DETAILS |
| + MachineToken + MachineType | **`claimable:true`,2 条,含 CLAIM_BENEFIT/amount:100** |
| 全套 − MachineToken(或 − MachineType) | 退回 1 条(失效) |
⇒ 两值**必须成对**,`Cosy-ClientType` 是必要但不充分条件。
## 实现(三级,与参考一致)
1. **主路径**:实时调 `runtime-info`,参数顺序必须是 `[environment, '--account-stdin']`,
`environment='3'`(对应 asar 的 `'global'` 分支)。**漏环境值会拿到另一套身份**
(参考实测:漏了只回 1 条 VIEW_DETAILS)。stdin 传 `{"account":""}`,
从输出第一个 `{` 起解析 `machineToken`/`machineType`。
定位方式照参考:`<home>/.qoder[|-cn]/.bin/umid-*/runtime-info`(目录名带哈希,必须枚举)。
2. **退路**:磁盘缓存 `machine_token.json` 的 `token`/`type`(候选路径含 Linux
`~/.config/Qoder/…`,并支持 `QODER_MACHINE_TOKEN_PATH` 覆盖)。
3. **降级**:都拿不到时**照常发请求、不带这两个头**(保守降级)——
纯插件登录、未装桌面端的账号没有该文件,不能让整个积分功能报错。
注意:**不写空串**(发空头可能被按非法设备处理)。
结果缓存(拿不到也记 null),不反复读盘/spawn。
## 接线
qoder 平台加 `headers_fn`:`Cosy-ClientType: 10` + (拿到时)成对的身份头,
余额与(后续的)活动领取共用。
## 判据
`TestQoderMachineIdentityPairingAndDegradation`:有缓存 → 成对带上;无任何来源 →
降级且不报错、仍带 clienttype。变异验证:只发 token(不成对)、拿不到时写空串
—— 两个都被抓。
## Lua 前向引用(本文件第 N 次)
`qoderMachineIdentity` 定义在文件后段,而 qoder 平台表在前面 —— 前向声明必须
放在 **PLATFORMS 之前**(第一次放错位置,等于没声明)。
This commit is contained in:
84
internal/core/channels_qoder_machine_test.go
Normal file
84
internal/core/channels_qoder_machine_test.go
Normal file
@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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:<home>/<dataDir>/.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)
|
||||
|
||||
Reference in New Issue
Block a user