Files
fnos-agent-checkin/AgentCheckin/app/backend/umid.py
T
cbb c263edc3e7 feat(qoder): 适配国际版每日签到 — UMID 设备风控身份 + 国际版活动 schema
核心发现(实测):
- Qoder 国际版服务端只对带 Cosy-MachineToken/Code/Type 的请求下发签到活动,
  否则 campaigns 返回空数组(国内版无此限制)
- 国际版活动 actionType=VIEW_DETAILS(国内版 CLAIM_BENEFIT),claimStatus 一致
- 活动窗口 2026-09-01~10-31,每日 10:00 (UTC+8) 刷新,领取后 30 天有效

实现:
- 新增 umid.py:从官方 npm 包 @qoder-ai/qodercli 下载并提取内嵌 UMID 二进制
  (x86_64/arm64 ELF),试跑校验后缓存到 @appdata/AgentCheckin/umid/
- qoder.py:签到前自动确保风控身份,支持国际版/国内版双 schema 活动解析
- API:/api/umid/status、/api/umid/install、/api/accounts/refresh_risk
- 前端:设置页风控身份面板(下载/状态)、账号列表「刷新风控」按钮
- 诊断:测连接返回 risk_identity / campaigns_debug(原始字段)

验证:NAS 实测 Qoder 国际版从「活动列表为空」变为「今日已领取(75 积分)」
测试:37 后端 + 25 E2E + 9 前端,全部通过
2026-10-19 12:04:44 +08:00

227 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Qoder UMID 设备风控身份(runtime-info)管理器。
背景:Qoder 服务端只向携带 Cosy-MachineToken / Cosy-MachineCode /
Cosy-MachineType 的请求下发「每天领 100 Credits」活动(社区实测,国际版尤其严格)。
这三个值由 Qoder 官方 UMID 程序 runtime-info 生成——桌面客户端自带,
官方 CLI 包 @qoder-ai/qodercli 也把各平台二进制以 base64 内嵌在 bundle/qodercli.js 中。
本模块在 NAS(Linux x86_64 / arm64)上下载官方 npm 包 → 提取对应架构的 ELF →
试跑一次确认可产出身份 → 缓存到应用数据目录(umid/)。
调用方式与客户端一致:
runtime-info <env> [--account-stdin],stdin 传入 {"account": "<uid>"}
env: 国际版 4(SINGAPORE)/ 国内版 0(ONLINE)
第一行输出 JSON:{"machineToken","machineCode","machineType","vmInfo"...}
安全性:只从官方 npm registry 下载官方包,二进制仅本机使用,不上传任何信息。
"""
import base64
import json
import os
import re
import stat
import subprocess
import sys
import time
import urllib.request
import ssl
from common import DATA_DIR, log, USER_AGENT
PKG = "@qoder-ai/qodercli"
REGISTRIES = [
"https://registry.npmmirror.com",
"https://registry.npmjs.org",
]
BUNDLE_ENTRY = "package/bundle/qodercli.js"
# ELF e_machine: x86_64=62, arm64=183
ELF_MACHINE = {"x86_64": 62, "aarch64": 183, "arm64": 183}
RISK_ENV = {"qoder": 4, "qoder-cn": 0} # 国际版 / 国内版
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE
CACHE_DIR = os.path.join(DATA_DIR, "umid")
BIN_PATH = os.path.join(CACHE_DIR, "runtime-info")
MANIFEST = os.path.join(CACHE_DIR, "manifest.json")
# ---------------------------------------------------------------------------
# 缓存
# ---------------------------------------------------------------------------
def installed():
"""已安装的 runtime-info:{path, version, arch, sha256, installedAt} 或 None."""
try:
m = json.load(open(MANIFEST, encoding="utf-8"))
if not os.path.isfile(BIN_PATH) or m.get("arch") != _arch():
return None
return dict(m, path=BIN_PATH)
except (OSError, ValueError):
return None
def _arch():
machine = os.uname().machine
return {"x86_64": "x86_64", "amd64": "x86_64",
"aarch64": "aarch64", "arm64": "aarch64"}.get(machine, machine)
# ---------------------------------------------------------------------------
# 下载 / 提取
# ---------------------------------------------------------------------------
def _fetch(url, timeout=180, binary=True):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as r:
return r.read()
def _registry_meta(registry):
meta = json.loads(_fetch(registry + "/" + PKG.replace("/", "%2F") + "/latest", 30).decode())
return meta
def _extract_tar_entry(tar, wanted):
"""最小 tar 解析:按名字取一个普通文件内容。"""
off = 0
long_name = None
while off + 512 <= len(tar):
h = tar[off:off + 512]
if not any(h):
break
name = h[:100].split(b"\0")[0].decode("utf-8", "replace")
size = int((h[124:136].split(b"\0")[0].strip() or b"0"), 8)
typeflag = h[156:157]
prefix = h[345:500].split(b"\0")[0].decode("utf-8", "replace")
full = (prefix + "/" + name) if prefix else name
if typeflag == b"L": # GNU long name
long_name = tar[off + 512:off + 512 + size].split(b"\0")[0].decode("utf-8", "replace")
elif typeflag in (b"0", b"\0"):
real = long_name or full
long_name = None
if real == wanted:
return tar[off + 512:off + 512 + size]
off += 512 + ((size + 511) // 512) * 512
return None
def _find_elf(bundle, arch):
"""在 bundle 里找指定架构的 ELF(base64 内嵌)。"""
want = ELF_MACHINE.get(arch)
if not want:
raise RuntimeError("不支持的 CPU 架构:%s" % arch)
for m in re.finditer(rb"[A-Za-z0-9+/]{100000,}", bundle):
try:
raw = base64.b64decode(m.group(0))
except Exception:
continue
if raw[:4] != b"\x7fELF" or len(raw) < 18:
continue
machine = int.from_bytes(raw[18:20], "little")
if machine == want:
return raw
raise RuntimeError("官方包中未找到 %s 架构的 UMID 二进制" % arch)
def install(force=False):
"""下载官方 qodercli 包并提取 runtime-info。返回 (ok, message)."""
if not force and installed():
return True, "已安装"
os.makedirs(CACHE_DIR, exist_ok=True)
arch = _arch()
if arch not in ELF_MACHINE:
return False, "NAS CPU 架构 %s 暂不支持(仅 x86_64 / arm64)" % arch
last_err = None
for registry in REGISTRIES:
try:
log("umid: fetching %s from %s" % (PKG, registry), "INFO")
meta = _registry_meta(registry)
version = meta.get("version", "")
tarball = (meta.get("dist") or {}).get("tarball") or ""
if not tarball:
raise RuntimeError("registry 未返回 tarball")
log("umid: downloading %s %s" % (PKG, version), "INFO")
tgz = _fetch(tarball, timeout=300)
# gzip 解包
import gzip
tar = gzip.decompress(tgz)
bundle = _extract_tar_entry(tar, BUNDLE_ENTRY)
if not bundle:
raise RuntimeError("包内未找到 %s" % BUNDLE_ENTRY)
elf = _find_elf(bundle, arch)
tmp = BIN_PATH + ".tmp"
with open(tmp, "wb") as f:
f.write(elf)
os.chmod(tmp, os.stat(tmp).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.replace(tmp, BIN_PATH)
import hashlib
sha = hashlib.sha256(elf).hexdigest()
json.dump({"version": version, "arch": arch, "sha256": sha,
"installedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
"registry": registry},
open(MANIFEST, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
log("umid: installed %s (arch=%s sha256=%s)" % (version, arch, sha[:12]), "INFO")
return True, "已安装 %s(%s)" % (version, arch)
except Exception as e: # 尝试下一个 registry
last_err = "%s: %s" % (registry, e)
log("umid: registry %s failed: %s" % (registry, e), "WARN")
return False, "下载失败(%s)" % last_err
# ---------------------------------------------------------------------------
# 调用
# ---------------------------------------------------------------------------
def risk_identity(provider="qoder", account="", force_install=False):
"""生成设备风控身份。返回 ({"machineToken","machineCode","machineType"}, error).
provider: "qoder"(国际版 env=4)/ "qoder-cn"(国内版 env=0)
"""
info = installed()
if not info or force_install:
ok, msg = install(force=force_install)
if not ok:
return None, msg
info = installed()
env = str(RISK_ENV.get(provider, 0))
try:
stdin_data = json.dumps({"account": str(account or "")}) + "\n"
p = subprocess.run([BIN_PATH, env, "--account-stdin"],
input=stdin_data, capture_output=True, text=True, timeout=30)
if p.returncode != 0 or not p.stdout.strip():
return None, "runtime-info 执行失败(退出码 %s)%s" % (p.returncode, (p.stderr or "")[:120])
line = p.stdout.strip().split("\n")[0]
data = json.loads(line)
out = {}
for k in ("machineToken", "machineCode", "machineType"):
v = data.get(k)
if not isinstance(v, str) or not v.strip():
return None, "runtime-info 输出缺少 %s" % k
out[k] = v.strip()
if data.get("vmInfo"):
out["vmInfo"] = data["vmInfo"]
return out, None
except subprocess.TimeoutExpired:
return None, "runtime-info 执行超时"
except Exception as e:
return None, "runtime-info 调用异常:%s" % e
def status():
"""供 UI 展示的状态。"""
info = installed()
return {
"installed": bool(info),
"path": BIN_PATH,
"version": (info or {}).get("version"),
"arch": (info or {}).get("arch"),
"installedAt": (info or {}).get("installedAt"),
"supported": _arch() in ELF_MACHINE,
"cpu_arch": _arch(),
}