diff --git a/cpa/schema.py b/cpa/schema.py index 33d1eb4..9210d8c 100644 --- a/cpa/schema.py +++ b/cpa/schema.py @@ -5,7 +5,11 @@ internal/auth/xai/token.go 的 TokenStorage 结构。 自带 x-grok-client-version 头(xai_executor.go 硬编码 0.2.93),免费 Build 账号 不会 426。 -2026-07:AccessToken 必须含 referrer=grok-build,否则 cli-chat-proxy 拒用。 +2026-07:AccessToken 必须同时满足: + 1) claim referrer=grok-build + 2) scope 含 grok-cli:access +否则 cli-chat-proxy 会返回: + WKE=unauthorized:grok-cli-token-auth-required 铸造请走 oidc_mint.oauth_code(SSO→Authorization Code + PKCE)。 """ diff --git a/oidc_mint/oauth_code.py b/oidc_mint/oauth_code.py index 75639cc..8d110b1 100644 --- a/oidc_mint/oauth_code.py +++ b/oidc_mint/oauth_code.py @@ -29,11 +29,14 @@ AUTHORIZE_URL = f"{ISSUER}/oauth2/authorize" # 兼容保留旧固定端口常量,仅作 fallback REDIRECT_URI_LEGACY = "http://127.0.0.1:56121/callback" REDIRECT_URI = REDIRECT_URI_LEGACY -# 对齐官方 docs + discovery:去掉二进制中已不出现的 grok-cli:access +# cli-chat-proxy 要求 access_token.scope 含 grok-cli:access,否则: +# WKE=unauthorized:grok-cli-token-auth-required +# 2026-07 一度误删该 scope(对齐 discovery),导致铸造成功但调用 403。 SCOPE = ( "openid profile email offline_access " - "api:access conversations:read conversations:write" + "grok-cli:access api:access conversations:read conversations:write" ) +GROK_CLI_SCOPE = "grok-cli:access" GROK_REFERRER = "grok-build" # 对齐官方 stable CLI(2026-07-14:0.2.101) GROK_VERSION = "0.2.101" @@ -503,6 +506,26 @@ def _run_sso_flow( log(f"WARN {msg}") else: log("access_token referrer=grok-build ok") + + # cli-chat-proxy 的 Grok CLI gate:缺 grok-cli:access 会直接 403 + scope_text = "" + try: + scope_text = str(jwt_payload(token.access_token).get("scope") or "") + except Exception: + scope_text = "" + scopes = set(scope_text.split()) + if GROK_CLI_SCOPE not in scopes: + msg = ( + f"access_token 缺少 {GROK_CLI_SCOPE} " + f"(scope={scope_text or '(empty)'});" + "cli-chat-proxy 会返回 grok-cli-token-auth-required" + ) + if require_referrer: + raise OAuthCodeError(msg) + log(f"WARN {msg}") + else: + log(f"access_token scope 含 {GROK_CLI_SCOPE} ok") + log(f"token ok expires_in={token.expires_in} refresh=yes") return token diff --git a/oidc_mint/oauth_device.py b/oidc_mint/oauth_device.py index dc4ce8f..d1047ca 100644 --- a/oidc_mint/oauth_device.py +++ b/oidc_mint/oauth_device.py @@ -20,10 +20,10 @@ CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" ISSUER = "https://auth.x.ai" DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code" TOKEN_URL = "https://auth.x.ai/oauth2/token" -# 对齐官方 0.2.101 discovery / docs(去掉二进制中已不出现的 grok-cli:access) +# cli-chat-proxy 要求 token scope 含 grok-cli:access(否则 grok-cli-token-auth-required) SCOPE = ( "openid profile email offline_access " - "api:access conversations:read conversations:write" + "grok-cli:access api:access conversations:read conversations:write" ) LogFn = Callable[[str], None] diff --git a/remint_cli_scope.py b/remint_cli_scope.py new file mode 100644 index 0000000..d8a8b82 --- /dev/null +++ b/remint_cli_scope.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""批量重铸 CPA auth:补回 grok-cli:access scope。 + +只覆盖写回,不删除原文件。默认仅处理缺 grok-cli:access 的 xai-*.json。 + +用法: + python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --dry-run + python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --limit 5 + python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --concurrency 6 +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from typing import Any + +_ROOT = Path(__file__).resolve().parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + + +def _jwt_payload(token: str) -> dict[str, Any]: + parts = (token or "").split(".") + if len(parts) < 2: + raise ValueError("invalid JWT") + seg = parts[1] + "=" * (-len(parts[1]) % 4) + return json.loads(base64.urlsafe_b64decode(seg.encode("ascii"))) + + +def _scope_set(token: str) -> set[str]: + try: + return set(str(_jwt_payload(token).get("scope") or "").split()) + except Exception: + return set() + + +def _needs_remint(data: dict[str, Any], *, only_missing_cli: bool) -> tuple[bool, str]: + at = str(data.get("access_token") or "").strip() + if not at: + return False, "no_access_token" + scopes = _scope_set(at) + has_cli = "grok-cli:access" in scopes + if only_missing_cli and has_cli: + return False, "already_has_cli_scope" + sso = str(data.get("sso") or "").strip() + if not sso: + return False, "no_sso" + return True, "missing_cli_scope" if not has_cli else "force" + + +def _atomic_write(path: Path, payload: dict[str, Any]) -> None: + tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}.{threading.get_ident()}") + text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, path) + + +def remint_one( + path: Path, + *, + proxy: str | None, + dry_run: bool, + only_missing_cli: bool, +) -> dict[str, Any]: + import cpa + from oidc_mint.oauth_code import GROK_CLI_SCOPE, mint_from_sso, normalize_sso_cookie + + try: + data = json.loads(path.read_text(encoding="utf-8", errors="replace")) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "path": str(path), "error": f"read: {exc}"} + + need, reason = _needs_remint(data, only_missing_cli=only_missing_cli) + email = str(data.get("email") or "").strip() + if not need: + return { + "ok": True, + "skipped": True, + "path": str(path), + "email": email, + "reason": reason, + } + + sso = normalize_sso_cookie(str(data.get("sso") or "")) + if dry_run: + return { + "ok": True, + "dry_run": True, + "path": str(path), + "email": email, + "reason": reason, + } + + try: + tokens = mint_from_sso( + sso, + proxy=proxy, + require_referrer=True, + log=lambda m: None, + ) + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "path": str(path), + "email": email, + "error": f"mint: {exc}", + "reason": reason, + } + + access = str(tokens.get("access_token") or "").strip() + refresh = str(tokens.get("refresh_token") or "").strip() + if GROK_CLI_SCOPE not in _scope_set(access): + return { + "ok": False, + "path": str(path), + "email": email, + "error": f"minted token still missing {GROK_CLI_SCOPE}", + "reason": reason, + } + + try: + payload = cpa.build_cpa_xai_auth( + email=email or str(data.get("email") or ""), + access_token=access, + refresh_token=refresh, + id_token=tokens.get("id_token") or data.get("id_token"), + expires_in=tokens.get("expires_in"), + base_url=data.get("base_url") or cpa.CLI_BASE_URL, + sso=sso, + headers=data.get("headers") if isinstance(data.get("headers"), dict) else None, + ) + # 保留原 disabled 状态,避免重铸把人工禁用号重新启用 + if "disabled" in data: + payload["disabled"] = bool(data.get("disabled")) + _atomic_write(path, payload) + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "path": str(path), + "email": email, + "error": f"write: {exc}", + "reason": reason, + } + + return { + "ok": True, + "path": str(path), + "email": email or payload.get("email"), + "reason": reason, + "referrer": payload.get("referrer"), + "scope_ok": True, + } + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Remint CPA auths missing grok-cli:access") + p.add_argument( + "--auth-dir", + default=os.environ.get("CPA_AUTH_DIR", str(_ROOT / "cpa_auths")), + help="CPA auth 目录(默认 ./cpa_auths 或 $CPA_AUTH_DIR)", + ) + p.add_argument("--proxy", default=os.environ.get("https_proxy") or os.environ.get("http_proxy") or "") + p.add_argument("--concurrency", type=int, default=4) + p.add_argument("--limit", type=int, default=0, help="最多处理 N 个需重铸文件(0=不限)") + p.add_argument("--dry-run", action="store_true") + p.add_argument( + "--all", + action="store_true", + help="强制全部重铸(默认只处理缺 grok-cli:access 的)", + ) + p.add_argument( + "--log-file", + default="", + help="结果日志路径(默认 auth-dir/remint_cli_scope.log)", + ) + args = p.parse_args(argv) + + auth_dir = Path(args.auth_dir).expanduser().resolve() + if not auth_dir.is_dir(): + print(f"[!] auth-dir 不存在: {auth_dir}", flush=True) + return 2 + + proxy = (args.proxy or "").strip() or None + only_missing_cli = not args.all + files = sorted(auth_dir.glob("xai-*.json")) + targets: list[Path] = [] + skipped = 0 + for path in files: + try: + data = json.loads(path.read_text(encoding="utf-8", errors="replace")) + need, _ = _needs_remint(data, only_missing_cli=only_missing_cli) + if need: + targets.append(path) + else: + skipped += 1 + except Exception: + skipped += 1 + if args.limit and len(targets) >= args.limit: + break + + log_path = Path(args.log_file).expanduser() if args.log_file else (auth_dir / "remint_cli_scope.log") + print( + f"[*] auth-dir={auth_dir} total={len(files)} targets={len(targets)} " + f"skipped_scan={skipped} concurrency={args.concurrency} " + f"proxy={proxy or '(direct)'} dry_run={args.dry_run}", + flush=True, + ) + if not targets: + print("[*] 无需重铸", flush=True) + return 0 + + ok_n = 0 + fail_n = 0 + skip_n = 0 + lock = threading.Lock() + started = time.time() + + def _handle(path: Path) -> dict[str, Any]: + return remint_one( + path, + proxy=proxy, + dry_run=args.dry_run, + only_missing_cli=only_missing_cli, + ) + + def _record(res: dict[str, Any]) -> None: + nonlocal ok_n, fail_n, skip_n + line = json.dumps(res, ensure_ascii=False) + with lock: + with open(log_path, "a", encoding="utf-8") as f: + f.write(line + "\n") + if res.get("skipped") or res.get("dry_run"): + skip_n += 1 + tag = "SKIP" if res.get("skipped") else "DRY" + elif res.get("ok"): + ok_n += 1 + tag = "OK" + else: + fail_n += 1 + tag = "FAIL" + email = res.get("email") or Path(str(res.get("path") or "")).name + extra = res.get("error") or res.get("reason") or "" + print(f"[{tag}] {email} {extra}", flush=True) + + # 清空/追加日志头 + with open(log_path, "a", encoding="utf-8") as f: + f.write( + json.dumps( + { + "event": "start", + "ts": int(time.time()), + "targets": len(targets), + "dry_run": bool(args.dry_run), + "proxy": proxy or "", + }, + ensure_ascii=False, + ) + + "\n" + ) + + if args.concurrency <= 1: + for path in targets: + _record(_handle(path)) + else: + with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as ex: + futs = [ex.submit(_handle, path) for path in targets] + for fut in as_completed(futs): + try: + _record(fut.result()) + except Exception as exc: # noqa: BLE001 + _record({"ok": False, "error": f"worker: {exc}"}) + + elapsed = time.time() - started + print( + f"[*] done ok={ok_n} fail={fail_n} skip/dry={skip_n} " + f"elapsed={elapsed:.1f}s log={log_path}", + flush=True, + ) + return 0 if fail_n == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main())