diff --git a/docs/channels-parity.md b/docs/channels-parity.md new file mode 100644 index 0000000..d9b409f --- /dev/null +++ b/docs/channels-parity.md @@ -0,0 +1,68 @@ +# channels 插件与参考实现(dsh-our-free-model / channel-pack)的对齐清单 + +参考位置:`vendor/channel-pack/src/`(本地克隆 `/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model/`)。 +对齐口径:**照对方代码行为逐行移植**,并配「与参考逐字节一致」的差分判据。 + +状态口径: +- ✅ 已对齐(有实现 + 有判据,关键算法与参考做逐字节差分) +- ⚠️ 部分对齐(主链路可用,高阶环节缺失) +- ❌ 未对齐(参考有的环节没有实现) + +## 一、登录(登录 = 拿到凭据并接入上游源) + +| 平台 | flow | 参考模块 | 状态 | 说明 | +|---|---|---|---|---| +| trae | callback | trae-oauth.ts | ✅ | 17 参数、machine/device_id(hex32)、login_trace_id=末16位、Cloud-IDE-JWT | +| lobsterai | callback | lobsterai-oauth.ts | ✅ | loopback /auth/callback、5 字段交换、版本号动态解析(远端 `data.value.version`) | +| gemini | callback | gemini-oauth.ts | ✅ | client_secret、/oauth-callback、localhost 主机、6 scope、access_type=offline | +| minimax | device-poll | minimax-oauth.ts | ✅ | PKCE S256 设备码、scope/audience | +| cline | device-poll | cline-oauth.ts | ✅ | WorkOS 设备码 → register 两跳 | +| qoder | device-poll | qoder-oauth.ts | ⚠️ | 登录是纯 HTTP PKCE(已对齐);**加密推理**需 WASM(见三) | +| zcode | init-poll | zcode-login.ts | ⚠️ | 登录链路已对齐;**领取**需滑块验证码(见三) | +| loomy | wechat-poll | loomy-wechat-login.ts | ✅ | 真 appId/uuid 正则、HMAC-SHA1 账号签名(openssl) | +| raccoon | qr-poll | raccoon-oauth.ts | ✅ | 本地 code、状态机 pending/logging/canceled/success | +| buddy | state-poll | buddy-oauth.ts | ✅ | state→token 轮询 | +| opencode | paste | opencode-auth.ts | ✅ | 手动 key | +| codearts | paste | — | ✅ | 手动凭据 | + +## 二、额度(领额度 = 每日领取 + 余额查询) + +| 平台 | 参考模块 | 状态 | 说明 | +|---|---|---|---| +| codearts | codearts-credits.ts | ✅ | 华为云 SDK-HMAC-SHA256 逐字节对齐(差分判据)+ 4 步领取(账户→活动→claim→confirm) | +| buddy | credits.ts | ✅ | 双层嵌套余额、身份头、只累加有效包 | +| trae | trae-credits.ts | ✅ | Cloud-IDE-JWT + 派生设备身份(差分判据) | +| lobsterai | lobsterai-credits.ts | ⚠️ | 余额已对齐(profile-summary);**每日活动领取缺**(`/api/client-activities[/slot]`) | +| qoder | qoder-credits.ts | ⚠️ | 余额已对齐(`/sash/api/v2/me/usage`);**活动领取缺**(`/sash/api/v1/me/campaigns`) | +| minimax | minimax-credits.ts | ✅ | 两步签到(status + claim) | +| cline | cline-quota.ts | ✅ | plan usage-limits | +| 其余 | — | ⚠️ | 参考无额度端点,如实显示"不可用"(不编造) | + +## 三、推理链路(高阶,参考有专门模块) + +| 平台 | 参考模块 | 状态 | 影响 | +|---|---|---|---| +| qoder | qoder-wasm.ts (706) + qoder-envelope.ts (226) + qoder-machine.ts (510) | ❌ | 公开端点只认少量通用模型名;**模型目录里的 key(qfmodel/dmodel…)需 WASM 加密端点** | +| zcode | zcode-captcha.ts (2119) + zcode-carrier*.ts | ❌ | 模型请求自 3.14.4 起**不再索要**验证码;**领取始终索要**(code 3007),且一次性 | +| gemini | gemini-sigstore.ts (268) | ❌ | thoughtSignature 缓存;缺失**不报错但可能悄悄降质** | +| loomy | loomy-sign.ts | ✅ | 已对齐(仅账号端点用,唯一调用不带 query) | +| 各平台 | captcha-*.ts (6 模块 1223 行) | ❌ | 验证码池/载体/退避 | + +## 四、差分判据(关键算法的"与参考一致"证据) + +| 判据 | 覆盖 | 生成器 | +|---|---|---| +| `TestCodeArtsSignatureMatchesReference` | 华为云 canonical / string-to-sign / signature / SignedHeaders / Authorization | `tools/codearts_sig_expected.mjs` | +| `TestTraeDeriveMatchesReference` | seededStream / device_id15 / market uuid / session id | `tools/trae_derive_expected.mjs` | +| `TestTraeCheckinHeaders` | Cloud-IDE-JWT + 派生设备身份 + 客户端头 | +| `TestCallbackPlatformsLoopback` | 三平台回调路径 + state 规则(Trae 无 state) | +| `TestLobsterLoopbackClosedLoop` | 有道 loopback 闭环 + state 拒绝 + 凭据完整性 | +| `TestXDisplaySelectionReclaimsStaleLocks` | 浏览器栈显示器锁回收(防号段烧穿) | + +## 五、已知未对齐项汇总(按对用户的影响排序) + +1. **qoder 加密推理(WASM)** — 影响:拿不到模型目录里的 key(如 Qwen 系列) +2. **zcode 领取验证码** — 影响:模型可用,但**每日额度领不到**(3007) +3. **lobsterai / qoder 活动领取** — 影响:余额能看,活动奖励领不到 +4. **gemini thoughtSignature 缓存** — 影响:工具调用质量可能悄悄下降 +5. **captcha 池/载体(6 模块)** — 影响:zcode 领取的前置基础设施 diff --git a/internal/core/codearts_sig_expected.json b/internal/core/codearts_sig_expected.json new file mode 100644 index 0000000..7f3a2b8 --- /dev/null +++ b/internal/core/codearts_sig_expected.json @@ -0,0 +1,38 @@ +[ + { + "method": "GET", + "url": "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin", + "body": "", + "ak": "AK1", + "sk": "SK1", + "st": "TOK1", + "canonical": "GET\n/snap-manager/v1/statistics/plugin/\n\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:TOK1\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\nf40918a3339c9db2251933dcce410db05ff8a403de2767bc642140ea93cdc182", + "signature": "5332d627c3b8f14973afe41905f23dce75eaf9a34134dff670cb19835beb0549", + "signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token" + }, + { + "method": "GET", + "url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE", + "body": "", + "ak": "AK2", + "sk": "SK2", + "st": "", + "canonical": "GET\n/v1/ops/delivery/\nchannel=IDE\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n3c849b40bbad69b981eaf280e162878bf00687d08cb45df43ac792fc23add4f1", + "signature": "a01248a4121fd943f0f3f07aac101cd649ddf34d162729bf06a6a12ae9916b1a", + "signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token" + }, + { + "method": "POST", + "url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim", + "body": "{\"campaignId\":\"C1\",\"channel\":\"IDE\"}", + "ak": "AK3", + "sk": "SK3", + "st": "T3", + "canonical": "POST\n/v1/ops/claim/\n\ncontent-type:application/json\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19\nx-sdk-date:20261013T150405Z\nx-security-token:T3\n\ncontent-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token\n85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19", + "string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n554c2607cff324a770b55e78e2a1c58afe466abf5e4eaa104a6e1fa96acfc733", + "signature": "e5faa52ec51c36b38804aac45a8a19876314cec465ab8f905100e657e8ea68d5", + "signed_headers": "content-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token" + } +] diff --git a/internal/core/codearts_sign_test.go b/internal/core/codearts_sign_test.go new file mode 100644 index 0000000..e3af629 --- /dev/null +++ b/internal/core/codearts_sign_test.go @@ -0,0 +1,97 @@ +package core + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端 +// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因 +// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状 +// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。 +// +// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成: +// +// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json +// +// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、 +// 以及参与签名的头列表(顺序与内容)。 +func TestCodeArtsSignatureMatchesReference(t *testing.T) { + raw, err := os.ReadFile("codearts_sig_expected.json") + if err != nil { + t.Skipf("缺差分数据(跳过): %v", err) + } + var cases []struct { + Method string `json:"method"` + URL string `json:"url"` + Body string `json:"body"` + AK string `json:"ak"` + SK string `json:"sk"` + ST string `json:"st"` + Canonical string `json:"canonical"` + StringToSign string `json:"string_to_sign"` + Signature string `json:"signature"` + SignedHeaders string `json:"signed_headers"` + } + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatal(err) + } + if len(cases) == 0 { + t.Fatal("差分数据为空") + } + + 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) + } + + for i, tc := range cases { + got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{ + "method": tc.Method, "url": tc.URL, "body": tc.Body, + "ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z", + }) + if err != nil { + t.Fatalf("case %d probe: %v", i, err) + } + w, _ := got.(map[string]interface{}) + m, _ := w["json"].(map[string]interface{}) + if m == nil { + t.Fatalf("case %d 无 json: %#v", i, got) + } + if m["error"] != nil { + t.Fatalf("case %d 探针报错: %v", i, m["error"]) + } + if s, _ := m["canonical"].(string); s != tc.Canonical { + t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical) + } + if s, _ := m["string_to_sign"].(string); s != tc.StringToSign { + t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign) + } + if s, _ := m["signature"].(string); s != tc.Signature { + t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature) + } + if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders { + t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders) + } + // Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=) + if s, _ := m["authorization"].(string); len(s) == 0 || + s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" { + t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s) + } + // 签名后追加的头绝不能进 canonical + if s, _ := m["canonical"].(string); len(s) > 0 { + for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} { + if strings.Contains(s, bad) { + t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad) + } + } + } + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 3b47244..0d422a1 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" } +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" } @@ -799,44 +799,205 @@ local PLATFORMS = { }, } --- 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 +-- 华为云 snap-access 引擎域名(连接与签名都用它)。 +local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com" + +-- CodeArts(华为云 snap-access)的请求签名与每日领取。 +-- +-- 逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`。 +-- 此前这里的实现是**凭印象编的**(用 `Credential={ak}/{ts}/{nonce}/sdk_request` +-- 那套阿里风格形状),与参考的华为云 SDK-HMAC-SHA256 完全不同 —— 签名不可能通过, +-- 这个平台注定领不到额度。对齐要点: +-- 1. canonical request = METHOD\nURI\nQUERY\n(排序后的 k:v 行)\n\nSIGNEDHEADERS\nPAYLOADHASH +-- 2. string to sign = "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}" +-- 3. signature = HMAC-SHA256(sk, stringToSign) +-- 4. Authorization = "SDK-HMAC-SHA256 Access={ak},SignedHeaders=...,Signature=..." +-- (**是 Access= 不是 Credential=**) +-- 5. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token +-- (+ 非 GET 时的 content-type);按字典序拼 SignedHeaders。 +-- 6. Agent-Type / X-Language 是**签名后**追加的,绝不能进 canonical(否则验签失败)。 +-- 7. URI 末尾必须补 '/'。 +local function codeartsDateStamp() + -- 参考用 ISO8601 去冒号与短横:20261013T150405Z + return os.date("!%Y%m%dT%H%M%SZ") +end + +-- codeartsSignedRequest(method, url, cred, body):返回可直接发出的 {url,method,headers,body}。 +local function codeartsSignedRequest(method, url, cred, body) + local ak = cred and (cred.access_key_id or cred.access_key) or "" + local sk = cred and (cred.secret_access_key or cred.secret_key) or "" + local st = cred and (cred.security_token or "") or "" + if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end + local host = url:match("^https?://([^/]+)") + local uri = url:match("^https?://[^/]+(/[^?]*)") or "/" + if uri:sub(-1) ~= "/" then uri = uri .. "/" end + local query = url:match("%?([^#]*)$") or "" + local payload = body or "" + local payloadHash = sha256_hex(payload) + local dateStamp = codeartsDateStamp() + + -- 参与签名的头(小写键),按字典序 + local h = { + ["host"] = host, + ["x-sdk-date"] = dateStamp, + ["x-sdk-content-sha256"] = payloadHash, + ["x-security-token"] = st, + } + if method:upper() ~= "GET" then h["content-type"] = "application/json" end + local keys = {} + for k in pairs(h) do keys[#keys + 1] = k end + table.sort(keys) + local lines, signedNames = {}, {} + for _, k in ipairs(keys) do + lines[#lines + 1] = k .. ":" .. h[k] + signedNames[#signedNames + 1] = k 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, - }, - } + local signedHeaders = table.concat(signedNames, ";") + local canonical = table.concat({ + method:upper(), uri, query, table.concat(lines, "\n"), "", signedHeaders, payloadHash, + }, "\n") + local stringToSign = "SDK-HMAC-SHA256\n" .. dateStamp .. "\n" .. sha256_hex(canonical) + local signature = hmac_sha256_hex(sk, stringToSign) + + -- 把中间值一并带出:判据必须比对**真实函数算出的** canonical / string-to-sign, + -- 而不是自己重算一遍 —— 否则真实签名被改坏时判据照样通过(判据与被测对象脱节, + -- 实测踩过:去掉 content-type 参与签名,探针重算版本仍绿)。 + -- 发出去的头:签名头 + Authorization(host 由 HTTP 栈生成,不手填) + local out = {} + for _, k in ipairs(keys) do + if k ~= "host" then out[k] = h[k] end + end + out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak + .. ",SignedHeaders=" .. signedHeaders .. ",Signature=" .. signature + -- 签名后追加(不参与 canonical) + out["Agent-Type"] = "PromptCenter" + out["X-Language"] = "zh-cn" + return { url = url, method = method:upper(), headers = out, body = payload, + canonical = canonical, string_to_sign = stringToSign, + signed_headers = signedHeaders } +end + +-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。 +local function codeartsAccountInfo(cred) + local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil) + if not req then return nil, err end + local resp, herr = __llmsproxy_http(req) + if herr or not resp or resp.status ~= 200 then + return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status)) + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "账户信息响应无法解析" end + local d = rec.data or rec + local metrics = d.metrics or {} + local total = nil + for _, m in ipairs(metrics) do + -- IDE 从 usageTotalPackageCredit 读总额(参考 codearts-credits.ts 注释) + if type(m) == "table" and m.name == "usageTotalPackageCredit" then + total = tonumber(m.value) or tonumber(m.credit) or total + end + end + local billing = d.billingMode or d.billing_mode or d.chargingMode or "" + return { + isCreditPackage = tostring(billing):upper():find("CREDIT") ~= nil, + isTokenPackage = tostring(billing):upper():find("TOKEN") ~= nil, + total = total, + raw = d, + }, nil +end + +-- codeartsClaim(plat, tok, args):完整领取流程(照参考的 6 步判定顺序)。 +-- 返回 (requestSpec, nil) 给通用发送路径,或 (nil, err) 提前失败。 +-- 注意:参考的流程要发**多个**请求(账户→活动→claim→confirm),而 claim_daily +-- 的契约只发一个。故这里把整段流程跑完并直接落结果,返回一个「不发送」的哨兵, +-- 由 claim_daily 识别 —— 见 CLAIM_DONE 约定。 +local function codeartsClaim(plat, tok, args) + local cred = (args and args.credential) or nil + if type(cred) ~= "table" then return nil, "缺少凭据对象(AK/SK 需要结构化字段)" end + + -- ① 账户类型:非积分账户 → inactive(Token 账户不该被报成"领取失败") + local info, ierr = codeartsAccountInfo(cred) + if not info then return nil, ierr end + if not info.isCreditPackage then + return { done = true, kind = "inactive", + message = info.isTokenPackage and "Token 计费账户,不在积分活动范围" + or "非积分计费账户,不在积分活动范围" } + end + + -- ② 活动列表 + local areq = codeartsSignedRequest("GET", + CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil) + if not areq then return nil, "活动列表请求构造失败" end + local aresp, aerr = __llmsproxy_http(areq) + if aerr or not aresp or aresp.status ~= 200 then + return { done = true, kind = "failed", + message = "活动列表查询失败: " .. tostring(aerr or (aresp and aresp.status)) } + end + local aok, arec = pcall(json.decode, aresp.body or "") + local items = aok and type(arec) == "table" and ((arec.data or {}).items) or nil + if type(items) ~= "table" then + return { done = true, kind = "failed", message = "活动列表响应缺少 items" } + end + local act = nil + for _, it in ipairs(items) do + if type(it) == "table" and (it.type or "") == "USER_LOGIN" then act = it break end + end + if not act then + return { done = true, kind = "inactive", message = "未找到每日签到活动" } + end + + -- ③ 幂等保护:本协议没有幂等键,预检不可省 + if act.claimable ~= true then + local st = tostring(act.status or "") + -- 已领取态(参考 CLAIMED_STATUSES) + local claimed = st == "CLAIMED" or st == "RECEIVED" or st == "COMPLETED" + return { done = true, kind = claimed and "already-claimed" or "inactive", + message = claimed and "今天已领取" or ("当前不可领取(status=" .. st .. ")") } + end + local campaignId = act.campaignId or act.campaign_id or "" + if campaignId == "" then + return { done = true, kind = "failed", message = "活动缺少 campaignId,无法领取" } + end + + -- ④ claim + local body = json.encode({ campaignId = campaignId, channel = "IDE" }) + local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body) + if not creq then return nil, "claim 请求构造失败" end + local cresp, cerr = __llmsproxy_http(creq) + if cerr or not cresp or cresp.status ~= 200 then + return { done = true, kind = "failed", + message = "领取失败: " .. tostring(cerr or (cresp and cresp.status)) } + end + + -- ⑤ 服务端要求确认时补 confirm(判据 benefit.id 非空);confirm 失败不算整体失败 + local msg = "已领取" + local cok, crec = pcall(json.decode, cresp.body or "") + local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil + if type(benefit) == "table" and benefit.id then + local fbody = json.encode({ id = benefit.id, channel = "IDE" }) + local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody) + if freq then + local fresp, ferr = __llmsproxy_http(freq) + -- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态) + if ferr or not fresp or fresp.status ~= 200 then + msg = "已领取(确认步骤失败,可能停在待确认)" + end + end + end + return { done = true, kind = "success", message = msg } +end + +-- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm), +-- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "codearts" then + p.claim_fn = function(plat, tok, args) return codeartsClaim(plat, tok, args) end + break end end end + -- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。 -- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。 local function declaredHosts() @@ -3735,6 +3896,19 @@ function plugin.claim_daily(req) if custom then spec = custom end end + -- done 哨兵:平台自己跑完了多步流程(如 codearts 的 账户→活动→claim→confirm), + -- 不再由通用路径发送单个请求。 + if type(spec) == "table" and spec.done then + local acct0 = acctEntry or plugin.state.accounts[plat.id] or {} + acct0.last_claim = os.time and os.time() or 0 + acct0.last_claim_status = spec.kind == "success" and 200 or 0 + acct0.last_claim_summary = spec.message + if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end + logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind)) + return { status = 200, json = { ok = spec.kind == "success", + outcome = { kind = spec.kind, message = spec.message } } } + end + local resp, err = __llmsproxy_http(spec) if err then logEvent("领取失败 " .. plat.id .. ": " .. err) @@ -5350,9 +5524,24 @@ do end end --- plugin.__trae_derive_probe 只给测试做差分用(拿参考实现生成的期望值比对)。 --- 不挂 HTTP 路由、不暴露给 UI;它必须在派生函数**定义之后**才存在, --- 因为它们都是 local,插件的 plugin 表又建得早。 +-- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。 +-- 允许传入 date 以对齐参考的固定时间戳。 +function plugin.__codearts_sign_probe(args) + args = args or {} + local date = tostring(args.date or "") + local saved = codeartsDateStamp + if date ~= "" then codeartsDateStamp = function() return date end end + local req, err = codeartsSignedRequest(tostring(args.method or "GET"), tostring(args.url or ""), + { access_key_id = tostring(args.ak or ""), secret_access_key = tostring(args.sk or ""), + security_token = tostring(args.st or "") }, tostring(args.body or "")) + codeartsDateStamp = saved + if not req then return { json = { error = err } } end + -- 直接用真实函数算出的中间值(不是重算)—— 否则判据与实现脱节。 + return { json = { canonical = req.canonical, string_to_sign = req.string_to_sign, + signature = hmac_sha256_hex(tostring(args.sk or ""), req.string_to_sign), + signed_headers = req.signed_headers, + authorization = req.headers["Authorization"] } } +end function plugin.__trae_derive_probe(args) local uid = tostring((type(args) == "table" and args.uid) or "") return { json = { devid = traeDeriveDeviceId15(uid), diff --git a/tools/codearts_sig_expected.mjs b/tools/codearts_sig_expected.mjs new file mode 100644 index 0000000..80f0353 --- /dev/null +++ b/tools/codearts_sig_expected.mjs @@ -0,0 +1,113 @@ +// 生成 CodeArts 华为云签名派生的差分期望值(供 internal/core/codearts_sign_test.go 比对)。 +// +// 为什么需要:channels.lua 里的华为云 SDK-HMAC-SHA256 签名是**手工移植** +// (Lua 没有 webcrypto)。签名错一处,服务端只回一句 +// `{"error_code":"APIG.0301","error_msg":"...verify ak sk signature fail"}`, +// 看起来像"账号/权限问题",极难定位。所以拿参考实现生成期望值做逐字节差分。 +// +// 参考真值:vendor/channel-pack/src/sign.ts 的 signRequestHuawei。 +// 这里**逐行照抄**那个函数(而非 import)—— 它依赖参考仓库的构建链, +// 抄这 40 行更能保证"同一算法"。 +// +// 用法(改了参考或改了移植后都要重跑): +// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json + +import { createHash, createHmac } from 'node:crypto' + +const sha256Hex = (d) => createHash('sha256').update(d).digest('hex') +const hmacSha256Hex = (key, data) => createHmac('sha256', key).update(data).digest('hex') + +/** 照抄 sign.ts:signRequestHuawei(dateStamp 提为参数以便固定时间)。 */ +function signRequestHuawei(ak, sk, securityToken, method, urlStr, body, dateStamp) { + const url = new URL(urlStr) + let uri = url.pathname + if (!uri.endsWith('/')) uri += '/' + const query = url.search.slice(1) + const payloadHash = sha256Hex(body) + + const headers = new Map() + headers.set('host', url.host) + headers.set('x-sdk-date', dateStamp) + headers.set('x-sdk-content-sha256', payloadHash) + headers.set('x-security-token', securityToken) + if (method.toUpperCase() !== 'GET') headers.set('content-type', 'application/json') + + const signedHeaders = [] + headers.forEach((_, k) => signedHeaders.push(k)) + signedHeaders.sort() + const headerLines = signedHeaders.map((k) => `${k}:${headers.get(k) ?? ''}`) + const canonical = [ + method, + uri, + query, + headerLines.join('\n'), + '', + signedHeaders.join(';'), + payloadHash, + ].join('\n') + const stringToSign = `SDK-HMAC-SHA256\n${dateStamp}\n${sha256Hex(new TextEncoder().encode(canonical))}` + const signature = hmacSha256Hex( + new TextEncoder().encode(sk), + new TextEncoder().encode(stringToSign), + ) + return { + canonical, + stringToSign, + signature, + signedHeaders: signedHeaders.join(';'), + } +} + +// 覆盖三条真实路径(账户查询 / 活动列表带 query / 领取带 body), +// 以及有/无 security_token 两种形态。 +const cases = [ + { + m: 'GET', + u: 'https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin', + b: '', + ak: 'AK1', + sk: 'SK1', + st: 'TOK1', + }, + { + m: 'GET', + u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE', + b: '', + ak: 'AK2', + sk: 'SK2', + st: '', + }, + { + m: 'POST', + u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim', + b: '{"campaignId":"C1","channel":"IDE"}', + ak: 'AK3', + sk: 'SK3', + st: 'T3', + }, +] + +const out = cases.map((c) => { + const r = signRequestHuawei( + c.ak, + c.sk, + c.st, + c.m, + c.u, + Buffer.from(c.b, 'utf8'), + '20261013T150405Z', + ) + return { + method: c.m, + url: c.u, + body: c.b, + ak: c.ak, + sk: c.sk, + st: c.st, + canonical: r.canonical, + string_to_sign: r.stringToSign, + signature: r.signature, + signed_headers: r.signedHeaders, + } +}) +process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)