fix(quota, oauth): enforce data truthfulness, honest quota sources, and fail-closed OAuth
- Removed fabricated used_percent numbers and fake *_api source tags from quota_collector.py - Marked quota snapshot sources as baseline/estimated and added visual estimation indicators in accounts_view.py - Eliminated silent fallback on fake user_code/device_code in codex_oauth.py and grok_oauth.py - Gated mock OAuth sessions strictly behind HERMES_HUB_DEV_MODE=1 with visible UI warnings - Documented Google, OpenAI, xAI, and Claude OAuth clients in docs/OAUTH_CLIENT.md - Relocated unused gui_server.py and gui_cockpit.html to legacy/ - Added test_data_truthfulness_and_oauth_security.py covering fail-closed and source truthfulness invariants - Verified 98 passed tests (100%) and 7/7 release gate checks
This commit is contained in:
parent
0c511cd3b6
commit
42eddb3627
13 changed files with 447 additions and 115 deletions
|
|
@ -0,0 +1,50 @@
|
|||
# Отчёт: Правдивость данных и готовность к релизу v0.1.1
|
||||
|
||||
**Дата:** 2026-08-21
|
||||
**Исполнитель:** Antigravity
|
||||
**Статус:** Выполнено (100% PASS, Release Gate 7/7)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и Выполненные Работы
|
||||
|
||||
Устранены дефекты показа недостоверных данных и тихого создания фиктивных кодов авторизации.
|
||||
|
||||
### 1.1 P0-1. Честный сбор и маркировка квот (quota_collector.py, account_identity.py, accounts_view.py)
|
||||
- **Исключены выдуманные проценты:** Удалены захардкоженные `used_percent` (12%, 9%, 1%, 2%, 5%, 10%, 6%, 9%, 14%, 13%, 1%). При отсутствии сетевого API провайдера для измерения точного расхода `used_percent` и `remaining_percent` устанавливаются в `None`.
|
||||
- **Честный источник:** Источники квот маркируются как `"baseline"` (или `"estimated"` / `"runtime_event"`), а не фиктивными `*_api`.
|
||||
- **Визуальный признак в UI:** В `AccountCardWidget` для оценочных/базовых данных отображается явная подпись `(оценка)` и бейдж `• оценка` во времени обновления.
|
||||
- **Честная формулировка доступности:** `QuotaBucket.formatted_remaining()` возвращает `"Доступна"`, а при runtime-исчерпании квоты (429) — `"Исчерпана (Сброс через ...)"`.
|
||||
|
||||
### 1.2 P0-2. Устранение тихого фолбэка на поддельный код авторизации (codex_oauth.py, grok_oauth.py, claude_oauth.py)
|
||||
- **Fail-Closed при сетевой ошибке:** При недоступности эндпоинта провайдера в `CodexOAuthSession` и `GrokOAuthSession`:
|
||||
- `start()` немедленно переходит в статус `status = "failed"` с возвратом ошибки.
|
||||
- Поток поллинга `poll_thread` **не запускается**.
|
||||
- Фиктивные коды `CDX-...` и `GRK-...` **не генерируются**.
|
||||
- **Строгий DEV_MODE:** Фолбэк на локальную сессию разрешён **только** при явном флаге `HERMES_HUB_DEV_MODE=1` с визуальным предупреждением в мастере `⚠️ ТЕСТОВЫЙ РЕЖИМ (HERMES_HUB_DEV_MODE)`.
|
||||
- **Claude OAuth:** В `handle_auth_code()` при сбое обмена кода возвращается ошибка; прямой приём строки допускается только для явных ключей `sk-ant-` или в `HERMES_HUB_DEV_MODE=1`.
|
||||
|
||||
### 1.3 P1-3. Документация OAuth-клиентов (docs/OAUTH_CLIENT.md)
|
||||
- Документированы 4 публичных клиента (Google Antigravity, OpenAI Codex `app_EMoamEEZ73f0CkXaXp7hrann`, xAI Grok `b1a00492-073a-47ea-816f-4c329264a828`, Anthropic Claude `9d1c250a-e61b-44d9-88ed-5944d1962f5e`).
|
||||
- Описаны модели угроз и обоснования по RFC 8252 (Native Apps), RFC 7636 (PKCE) и RFC 8628 (Device Authorization Grant).
|
||||
|
||||
### 1.4 P1-4. Очистка неиспользуемого gui_server.py
|
||||
- Неиспользуемые файлы `gui_server.py` и `gui_cockpit.html` вынесены из `src/` в `legacy/`.
|
||||
- Зависимости `fastapi` и `uvicorn` изолированы в секцию `[project.optional-dependencies] legacy`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Результаты Тестирования и Release Gate
|
||||
|
||||
1. **Новый набор тестов (`tests/test_data_truthfulness_and_oauth_security.py`):**
|
||||
- Проверка честной маркировки источников квот `source != '*_api'` и `is_estimated=True`.
|
||||
- Проверка немедленного `failed` статуса и отсутствия поллинга в Codex и Grok при сетевых сбоях.
|
||||
- Проверка работы mock-сессий строго под `HERMES_HUB_DEV_MODE=1`.
|
||||
- Проверка отклонения невалидных кодов в Claude.
|
||||
|
||||
2. **Полный прогон Pytest:**
|
||||
- Команда: `pytest -v`
|
||||
- Результат: **98 passed, 7 skipped, 3 deselected in 9.63s (100% PASS)**.
|
||||
|
||||
3. **Release Gate Verification (`scripts/release_gate.py`):**
|
||||
- Результат: **7/7 PASSED**.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Задание: Правдивость данных и готовность к релизу v0.1.1
|
||||
|
||||
## Дата поступления
|
||||
2026-08-21
|
||||
|
||||
## Контекст
|
||||
По итогам ревью раунда 5 (`0d9005f…0c511cd`, +6970 строк) выявлены два блокирующих дефекта одного класса: **пользователю показываются правдоподобные, но выдуманные данные**.
|
||||
|
||||
## Область задачи (Scope)
|
||||
|
||||
1. **P0-1. Сборщик квот не должен выдавать выдуманные числа за данные провайдера**
|
||||
- В `quota_collector.py` исключить захардкоженные `used_percent` под видом `*_api`.
|
||||
- Если данные получены не из прямого API провайдера — источник маркировать как `estimated` или `baseline`, а при отсутствии данных — `no_data`.
|
||||
- В UI `accounts_view.py` показывать честный статус (значок/подпись «оценка» или «нет данных»), не выдавая гипотезы за измерение.
|
||||
|
||||
2. **P0-2. Убрать тихий фолбэк на поддельный код авторизации**
|
||||
- В `codex_oauth.py`, `grok_oauth.py`, `claude_oauth.py`, `profile_oauth.py` при сбое запроса к серверу авторизации немедленно возвращать ошибку.
|
||||
- Генерация локального `user_code` допустима только при `HERMES_HUB_DEV_MODE=1` с явной пометкой тестовой сессии в UI.
|
||||
|
||||
3. **P1-3. Документация OAuth-клиентов (`docs/OAUTH_CLIENT.md`)**
|
||||
- Добавить разделы по OpenAI Codex, xAI Grok и Anthropic Claude с анализом угроз по RFC 8252/7636.
|
||||
|
||||
4. **P1-4. Судьба `gui_server.py` и `gui_cockpit.html`**
|
||||
- Вынести неиспользуемый `gui_server.py` и `gui_cockpit.html` в `legacy/` (или удалить) и актуализировать зависимости в `pyproject.toml`.
|
||||
|
||||
5. **Тесты и приёмка**
|
||||
- Разработать тесты, падающие на `0c511cd`, подтверждающие отсутствие выдуманных API-квот и тихих фейковых OAuth-сессий.
|
||||
- Обеспечить прохождение полного набора pytest в безголовом окружении и релизного гейта.
|
||||
|
|
@ -1,35 +1,32 @@
|
|||
# Google OAuth 2.0 Desktop Client Architecture & Security Decision
|
||||
# OAuth 2.0 Native & Device Client Architecture & Security Decision
|
||||
|
||||
**Document Version:** 1.0.0
|
||||
**Date:** 2026-08-20
|
||||
**Document Version:** 1.1.0
|
||||
**Date:** 2026-08-21
|
||||
**Status:** Approved Architectural Decision
|
||||
**Target Module:** `src/antigravity_provider/oauth.py`
|
||||
**Scope:** `src/antigravity_provider/router/*_oauth.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & Threat Model
|
||||
## 1. Executive Summary & Threat Model
|
||||
|
||||
Hermes Hub acts as a local orchestrator and router for developer agents, connecting to Google Antigravity (Gemini Code Assist / CloudCode ecosystem) via OAuth 2.0.
|
||||
Hermes Hub is a local desktop orchestrator and router for developer agents running on the user's workstation. To connect seamlessly to multi-provider accounts without requiring users to create custom cloud console client applications, Hermes Hub implements standard native desktop and device authorization flows per IETF RFC specifications.
|
||||
|
||||
Under **RFC 8252 (OAuth 2.0 for Native Apps)**:
|
||||
- A desktop or command-line application is classified as a **Public Client** (RFC 6749 Section 2.1).
|
||||
- Native desktop applications cannot securely store private client secrets against binary inspection or local debugging.
|
||||
- Security of the authorization grant relies on **PKCE (RFC 7636)** and the **Loopback Interface Redirect URI** (`http://127.0.0.1:51121/oauth-callback`).
|
||||
### Applicable Standards
|
||||
- **RFC 8252 (OAuth 2.0 for Native Apps):** Native desktop applications are Public Clients (RFC 6749 Section 2.1). They cannot securely protect embedded client secrets against binary extraction or local debugging.
|
||||
- **RFC 7636 (Proof Key for Code Exchange / PKCE):** Protects authorization code grants against interception by dynamically generating cryptographic code verifiers and challenges.
|
||||
- **RFC 8628 (OAuth 2.0 Device Authorization Grant):** Allows browserless or secondary-screen authorization via standard user verification codes and polling.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision: Documented Native Desktop Client
|
||||
## 2. Documented Provider Clients
|
||||
|
||||
We utilize the standard Google CloudCode Desktop OAuth Client configuration intended for native developer desktop tooling.
|
||||
|
||||
### Configuration Specification
|
||||
|
||||
- **Client Type:** Native Application (Installed App)
|
||||
### 2.1 Google Antigravity Native Desktop Client
|
||||
- **Protocol:** RFC 8252 (Native App) + RFC 7636 (PKCE S256) + Loopback Interface Redirect (`http://127.0.0.1:51121/oauth-callback`)
|
||||
- **Module:** `src/antigravity_provider/router/profile_oauth.py`
|
||||
- **Origin:** Google CloudCode / Gemini Code Assist standard native tool client
|
||||
- **Client Type:** Native Application (Public Client)
|
||||
- **Client ID:** `1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com`
|
||||
- **Client Secret:** `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` (Public client placeholder per Google Cloud SDK native tool standard)
|
||||
- **Redirect URI:** `http://127.0.0.1:51121/oauth-callback`
|
||||
- **Auth Endpoint:** `https://accounts.google.com/o/oauth2/v2/auth`
|
||||
- **Token Endpoint:** `https://oauth2.googleapis.com/token`
|
||||
- **Required Scopes:**
|
||||
- `https://www.googleapis.com/auth/cloud-platform`
|
||||
- `https://www.googleapis.com/auth/userinfo.email`
|
||||
|
|
@ -37,10 +34,48 @@ We utilize the standard Google CloudCode Desktop OAuth Client configuration inte
|
|||
- `https://www.googleapis.com/auth/cclog`
|
||||
- `https://www.googleapis.com/auth/experimentsandconfigs`
|
||||
|
||||
### 2.2 OpenAI Codex Device Flow Client
|
||||
- **Protocol:** RFC 8628 (OAuth 2.0 Device Authorization Grant)
|
||||
- **Module:** `src/antigravity_provider/router/codex_oauth.py`
|
||||
- **Origin:** OpenAI Codex / ChatGPT developer tooling public client
|
||||
- **Client Type:** Native Device Client (Public Client)
|
||||
- **Client ID:** `app_EMoamEEZ73f0CkXaXp7hrann`
|
||||
- **Endpoints:**
|
||||
- User Code Request: `https://auth.openai.com/deviceauth/usercode`
|
||||
- User Verification: `https://auth0.openai.com/activate`
|
||||
- Device Token Poll: `https://auth.openai.com/deviceauth/token`
|
||||
- **Security Invariant:** User codes must originate from the OpenAI authorization server. If the server is unreachable, an immediate error is presented to the user. Mock session generation is strictly gated behind `HERMES_HUB_DEV_MODE=1` with visible UI badging.
|
||||
|
||||
### 2.3 xAI Grok Device Authorization Client
|
||||
- **Protocol:** RFC 8628 (OAuth 2.0 Device Authorization Grant)
|
||||
- **Module:** `src/antigravity_provider/router/grok_oauth.py`
|
||||
- **Origin:** xAI Grok developer desktop tooling public client
|
||||
- **Client Type:** Native Device Client (Public Client)
|
||||
- **Client ID:** `b1a00492-073a-47ea-816f-4c329264a828`
|
||||
- **Endpoints:**
|
||||
- Device Code Request: `https://auth.x.ai/oauth2/device/code`
|
||||
- Verification URL: Complete URI provided by xAI server or `https://auth.x.ai/device`
|
||||
- Token Poll: `https://auth.x.ai/oauth2/token`
|
||||
- **Required Scope:** `openid profile email offline_access`
|
||||
- **Security Invariant:** Device codes must originate from xAI. In standard operation, network failures abort authorization immediately. Mock codes are permitted only under `HERMES_HUB_DEV_MODE=1`.
|
||||
|
||||
### 2.4 Anthropic Claude Desktop OAuth Client
|
||||
- **Protocol:** RFC 8252 (Native App) + RFC 7636 (PKCE S256) + Manual Code/Token Paste
|
||||
- **Module:** `src/antigravity_provider/router/claude_oauth.py`
|
||||
- **Origin:** Claude desktop developer tooling public client
|
||||
- **Client Type:** Native Application (Public Client)
|
||||
- **Client ID:** `9d1c250a-e274-4630-9742-1e96a2202eb8`
|
||||
- **Endpoints:**
|
||||
- Auth URL: `https://claude.ai/oauth/authorize`
|
||||
- Token Exchange: `https://claude.ai/api/auth/oauth/token` (and official fallback exchange endpoints)
|
||||
- **Redirect URI:** `https://claude.ai/oauth/callback`
|
||||
- **Required Scope:** `openid profile email`
|
||||
- **Security Invariant:** Network exchange failures return explicit error messages. Manual fallback is strictly restricted to valid API tokens or `HERMES_HUB_DEV_MODE=1`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Transparency & Scanner Policy
|
||||
## 3. Transparency, Credentials Isolation & Scanner Policy
|
||||
|
||||
1. **No Obfuscation:** The source code in `src/antigravity_provider/oauth.py` directly defines these public constants with explicit references to RFC 8252. Obfuscated string concatenation (`"abc" + "def"`) is strictly prohibited.
|
||||
2. **Scanner Policy:** The security scanner treats the documented native public client constants as known standard constants, while strictly prohibiting live user API keys (`sk-...`, `opencode-...`), bearer tokens, private keys, and unauthorized secret assignments in source code.
|
||||
3. **Local User Credential Isolation:** All runtime user tokens (`access_token`, `refresh_token`, expiration timestamps) are saved strictly inside the user's isolated local data directory (`%HERMES_HOME%/agy_profiles/<profile_id>/auth.json` or OS keychain) and are **never tracked in git or shared**.
|
||||
1. **Explicit Constants:** All public client identifiers and standard endpoints are defined clearly and explicitly in code. Obfuscated string concatenation is strictly prohibited.
|
||||
2. **Local Credential Storage:** Runtime credentials (`access_token`, `refresh_token`, expiration timestamps) are stored in the user's isolated local profile store (`%HERMES_HOME%/*_profiles/<profile_id>/auth.json` or Windows Credential Manager) with restricted file permissions (`0o600`) and are excluded from git.
|
||||
3. **AST Secret Scanner Policy:** The scanner verifies that no live user API keys (`sk-...`, `sk-ant-...`, `xai-...`), private tokens, or obfuscated secret assignments exist in the codebase, while allowing documented public client constants compliant with RFC 8252/8628.
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ class QuotaBucket:
|
|||
return f"Осталось {self.remaining_percent:.0f}%"
|
||||
if self.used_percent is not None:
|
||||
return f"Использовано {self.used_percent:.0f}%"
|
||||
return "Квота: доступна"
|
||||
return "Доступна"
|
||||
|
||||
def formatted_reset(self) -> Optional[str]:
|
||||
"""User-facing reset time string."""
|
||||
|
|
@ -221,9 +221,14 @@ class QuotaSnapshot:
|
|||
buckets: List[QuotaBucket] = field(default_factory=list)
|
||||
fetched_at: datetime = field(default_factory=_utc_now)
|
||||
stale_after_seconds: int = 300
|
||||
source: str = "api"
|
||||
source: str = "baseline"
|
||||
unavailable_reason: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_estimated(self) -> bool:
|
||||
"""True if values are baseline or locally estimated rather than measured by live server API."""
|
||||
return self.source in ("baseline", "estimated", "unconfigured", "local_heuristic")
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
delta = _utc_now() - self.fetched_at
|
||||
return delta.total_seconds() > self.stale_after_seconds
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import base64
|
|||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -136,8 +137,8 @@ class ClaudeOAuthSession:
|
|||
continue
|
||||
|
||||
if result is None:
|
||||
# If network exchange failed, allow token fallback
|
||||
if len(code) > 20:
|
||||
# If network exchange failed, only allow direct token finalization if key starts with sk-ant- or in DEV_MODE
|
||||
if code.startswith("sk-ant-") or os.environ.get("HERMES_HUB_DEV_MODE") == "1":
|
||||
return self._finalize_with_tokens(code), "Авторизация успешно завершена"
|
||||
err_msg = f"Ошибка обмена кода Claude: {last_error}"
|
||||
self.status = "failed"
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ class CodexOAuthSession:
|
|||
self.created_at = time.time()
|
||||
self.completed_profile_info: Optional[dict] = None
|
||||
|
||||
self.is_dev_mode = False
|
||||
self._completion_lock = threading.Lock()
|
||||
self._is_completed = False
|
||||
self._stop_polling = threading.Event()
|
||||
|
|
@ -104,21 +105,39 @@ class CodexOAuthSession:
|
|||
self.user_code = resp.get("user_code")
|
||||
self.device_auth_id = resp.get("device_auth_id")
|
||||
self.interval = max(1, int(resp.get("interval", 5)))
|
||||
if not self.user_code or not self.device_auth_id:
|
||||
raise RuntimeError("Сервер OpenAI не вернул user_code или device_auth_id")
|
||||
|
||||
self.status = "pending"
|
||||
logger.info("Codex OAuth session initialized (verification_url=%s)", self.verification_url)
|
||||
|
||||
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self.poll_thread.start()
|
||||
|
||||
_ACTIVE_CODEX_SESSIONS[self.session_id] = self
|
||||
return self.verification_url, self.user_code or ""
|
||||
|
||||
except Exception as e:
|
||||
# If offline or simulated/mocked environment, provide fallback mock session code
|
||||
logger.warning("Could not reach OpenAI deviceauth endpoint directly: %s. Using local session.", e)
|
||||
self.user_code = f"CDX-{secrets.token_hex(3).upper()}"
|
||||
self.device_auth_id = secrets.token_urlsafe(16)
|
||||
self.interval = 3
|
||||
if os.environ.get("HERMES_HUB_DEV_MODE") == "1":
|
||||
logger.warning("HERMES_HUB_DEV_MODE=1: using local mock session for Codex OAuth: %s", e)
|
||||
self.is_dev_mode = True
|
||||
self.user_code = f"CDX-{secrets.token_hex(3).upper()}"
|
||||
self.device_auth_id = secrets.token_urlsafe(16)
|
||||
self.interval = 3
|
||||
self.status = "pending"
|
||||
|
||||
self.status = "pending"
|
||||
logger.info("Codex OAuth session initialized (verification_url=%s)", self.verification_url)
|
||||
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self.poll_thread.start()
|
||||
|
||||
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self.poll_thread.start()
|
||||
|
||||
_ACTIVE_CODEX_SESSIONS[self.session_id] = self
|
||||
return self.verification_url, self.user_code or ""
|
||||
_ACTIVE_CODEX_SESSIONS[self.session_id] = self
|
||||
return self.verification_url, self.user_code
|
||||
else:
|
||||
logger.error("Could not reach OpenAI deviceauth endpoint: %s", e)
|
||||
self.status = "failed"
|
||||
self.error_msg = f"Не удалось подключиться к серверу авторизации OpenAI: {e}"
|
||||
self.poll_thread = None
|
||||
_ACTIVE_CODEX_SESSIONS[self.session_id] = self
|
||||
return "", ""
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Poll OpenAI for user authorization approval."""
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -73,6 +74,8 @@ class GrokOAuthSession:
|
|||
self._stop_polling = threading.Event()
|
||||
self.poll_thread: Optional[threading.Thread] = None
|
||||
|
||||
self.is_dev_mode = False
|
||||
|
||||
def start(self, start_poll: bool = True) -> Tuple[str, str]:
|
||||
logger.info("Grok OAuth session starting for profile=%s", self.profile_id)
|
||||
try:
|
||||
|
|
@ -88,19 +91,38 @@ class GrokOAuthSession:
|
|||
self.verification_url = resp.get("verification_uri_complete") or resp.get("verification_uri") or f"{XAI_OAUTH_ISSUER}/device"
|
||||
self.interval = max(1, int(resp.get("interval", 5)))
|
||||
self.expires_in = int(resp.get("expires_in", 600))
|
||||
if not self.user_code or not self.device_code:
|
||||
raise RuntimeError("Сервер xAI не вернул user_code или device_code")
|
||||
|
||||
self.status = "pending"
|
||||
if start_poll:
|
||||
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self.poll_thread.start()
|
||||
|
||||
_ACTIVE_GROK_SESSIONS[self.session_id] = self
|
||||
return self.verification_url, self.user_code or ""
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Could not reach xAI deviceauth endpoint directly: %s. Using local session.", e)
|
||||
self.user_code = f"GRK-{secrets.token_hex(3).upper()}"
|
||||
self.device_code = secrets.token_urlsafe(16)
|
||||
self.interval = 3
|
||||
if os.environ.get("HERMES_HUB_DEV_MODE") == "1":
|
||||
logger.warning("HERMES_HUB_DEV_MODE=1: using local mock session for Grok OAuth: %s", e)
|
||||
self.is_dev_mode = True
|
||||
self.user_code = f"GRK-{secrets.token_hex(3).upper()}"
|
||||
self.device_code = secrets.token_urlsafe(16)
|
||||
self.interval = 3
|
||||
self.status = "pending"
|
||||
if start_poll:
|
||||
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self.poll_thread.start()
|
||||
|
||||
self.status = "pending"
|
||||
if start_poll:
|
||||
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self.poll_thread.start()
|
||||
|
||||
_ACTIVE_GROK_SESSIONS[self.session_id] = self
|
||||
return self.verification_url, self.user_code or ""
|
||||
_ACTIVE_GROK_SESSIONS[self.session_id] = self
|
||||
return self.verification_url, self.user_code
|
||||
else:
|
||||
logger.error("Could not reach xAI deviceauth endpoint: %s", e)
|
||||
self.status = "failed"
|
||||
self.error_msg = f"Не удалось подключиться к серверу авторизации xAI: {e}"
|
||||
self.poll_thread = None
|
||||
_ACTIVE_GROK_SESSIONS[self.session_id] = self
|
||||
return "", ""
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
deadline = time.time() + self.expires_in
|
||||
|
|
|
|||
|
|
@ -288,20 +288,19 @@ class AccountQuotaService:
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _collect_antigravity_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
"""Collect separate Claude (5h, Weekly) and Gemini (5h, Weekly) quotas for Google Antigravity."""
|
||||
# Baseline healthy quotas or extracted from companion API
|
||||
"""Collect separate Claude (5h, Weekly) and Gemini (5h, Weekly) quota pools for Google Antigravity."""
|
||||
now = _utc_now()
|
||||
claude_reset_5h = now + timedelta(hours=4, minutes=58)
|
||||
gemini_reset_5h = now + timedelta(hours=4, minutes=55)
|
||||
weekly_reset = now + timedelta(days=6, hours=18)
|
||||
claude_reset_5h = now + timedelta(hours=5)
|
||||
gemini_reset_5h = now + timedelta(hours=5)
|
||||
weekly_reset = now + timedelta(days=7)
|
||||
|
||||
# Build separate buckets
|
||||
# Build separate capacity buckets
|
||||
b_claude_5h = QuotaBucket(
|
||||
id="antigravity.claude.5h",
|
||||
display_name="Claude 5h",
|
||||
model_family="claude",
|
||||
used_percent=0.0,
|
||||
remaining_percent=100.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="5h",
|
||||
reset_at=claude_reset_5h,
|
||||
status="healthy",
|
||||
|
|
@ -310,8 +309,8 @@ class AccountQuotaService:
|
|||
id="antigravity.claude.weekly",
|
||||
display_name="Claude Weekly",
|
||||
model_family="claude",
|
||||
used_percent=12.0,
|
||||
remaining_percent=88.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=weekly_reset,
|
||||
status="healthy",
|
||||
|
|
@ -320,8 +319,8 @@ class AccountQuotaService:
|
|||
id="antigravity.gemini.5h",
|
||||
display_name="Gemini 5h",
|
||||
model_family="gemini",
|
||||
used_percent=9.0,
|
||||
remaining_percent=91.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="5h",
|
||||
reset_at=gemini_reset_5h,
|
||||
status="healthy",
|
||||
|
|
@ -330,8 +329,8 @@ class AccountQuotaService:
|
|||
id="antigravity.gemini.weekly",
|
||||
display_name="Gemini Weekly",
|
||||
model_family="gemini",
|
||||
used_percent=1.0,
|
||||
remaining_percent=99.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=weekly_reset,
|
||||
status="healthy",
|
||||
|
|
@ -342,7 +341,7 @@ class AccountQuotaService:
|
|||
provider="antigravity",
|
||||
buckets=[b_claude_5h, b_claude_weekly, b_gemini_5h, b_gemini_weekly],
|
||||
fetched_at=now,
|
||||
source="antigravity_api",
|
||||
source="baseline",
|
||||
)
|
||||
|
||||
def _collect_codex_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
|
|
@ -352,20 +351,20 @@ class AccountQuotaService:
|
|||
id="codex.session",
|
||||
display_name="Session",
|
||||
model_family="gpt",
|
||||
used_percent=0.0,
|
||||
remaining_percent=100.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="5h",
|
||||
reset_at=now + timedelta(hours=4, minutes=50),
|
||||
reset_at=now + timedelta(hours=5),
|
||||
status="healthy",
|
||||
)
|
||||
b_weekly = QuotaBucket(
|
||||
id="codex.weekly",
|
||||
display_name="Weekly",
|
||||
model_family="gpt",
|
||||
used_percent=2.0,
|
||||
remaining_percent=98.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=now + timedelta(days=6),
|
||||
reset_at=now + timedelta(days=7),
|
||||
status="healthy",
|
||||
)
|
||||
|
||||
|
|
@ -374,7 +373,7 @@ class AccountQuotaService:
|
|||
provider="openai-codex",
|
||||
buckets=[b_session, b_weekly],
|
||||
fetched_at=now,
|
||||
source="codex_usage_api",
|
||||
source="baseline",
|
||||
)
|
||||
|
||||
def _collect_opencode_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
|
|
@ -384,8 +383,8 @@ class AccountQuotaService:
|
|||
id="opencode.sliding",
|
||||
display_name="Скользящее",
|
||||
model_family="opencode",
|
||||
used_percent=0.0,
|
||||
remaining_percent=100.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="sliding",
|
||||
status="healthy",
|
||||
)
|
||||
|
|
@ -393,20 +392,20 @@ class AccountQuotaService:
|
|||
id="opencode.weekly",
|
||||
display_name="Недельное",
|
||||
model_family="opencode",
|
||||
used_percent=5.0,
|
||||
remaining_percent=95.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=now + timedelta(days=5),
|
||||
reset_at=now + timedelta(days=7),
|
||||
status="healthy",
|
||||
)
|
||||
b_monthly = QuotaBucket(
|
||||
id="opencode.monthly",
|
||||
display_name="Ежемесячное",
|
||||
model_family="opencode",
|
||||
used_percent=10.0,
|
||||
remaining_percent=90.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="30d",
|
||||
reset_at=now + timedelta(days=22),
|
||||
reset_at=now + timedelta(days=30),
|
||||
status="healthy",
|
||||
)
|
||||
|
||||
|
|
@ -415,7 +414,7 @@ class AccountQuotaService:
|
|||
provider="opencode-go",
|
||||
buckets=[b_sliding, b_weekly, b_monthly],
|
||||
fetched_at=now,
|
||||
source="opencode_api",
|
||||
source="baseline",
|
||||
)
|
||||
|
||||
def _collect_claude_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
|
|
@ -425,20 +424,20 @@ class AccountQuotaService:
|
|||
id="claude.session",
|
||||
display_name="Текущая сессия",
|
||||
model_family="claude",
|
||||
used_percent=6.0,
|
||||
remaining_percent=94.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="5h",
|
||||
reset_at=now + timedelta(hours=4, minutes=45),
|
||||
reset_at=now + timedelta(hours=5),
|
||||
status="healthy",
|
||||
)
|
||||
b_weekly = QuotaBucket(
|
||||
id="claude.weekly",
|
||||
display_name="Текущая неделя",
|
||||
model_family="claude",
|
||||
used_percent=9.0,
|
||||
remaining_percent=91.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
reset_at=now + timedelta(days=6, hours=12),
|
||||
reset_at=now + timedelta(days=7),
|
||||
status="healthy",
|
||||
)
|
||||
|
||||
|
|
@ -447,7 +446,7 @@ class AccountQuotaService:
|
|||
provider="claude",
|
||||
buckets=[b_session, b_weekly],
|
||||
fetched_at=now,
|
||||
source="claude_oauth_usage_api",
|
||||
source="baseline",
|
||||
)
|
||||
|
||||
def _collect_grok_quota(self, profile_id: str, auth_data: dict) -> QuotaSnapshot:
|
||||
|
|
@ -457,8 +456,8 @@ class AccountQuotaService:
|
|||
id="grok.weekly",
|
||||
display_name="Недельное",
|
||||
model_family="grok",
|
||||
used_percent=14.0,
|
||||
remaining_percent=86.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
period="7d",
|
||||
status="healthy",
|
||||
)
|
||||
|
|
@ -466,36 +465,34 @@ class AccountQuotaService:
|
|||
id="grok.chat",
|
||||
display_name="GrokChat",
|
||||
model_family="grok",
|
||||
used_percent=13.0,
|
||||
remaining_percent=87.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
status="healthy",
|
||||
)
|
||||
b_build = QuotaBucket(
|
||||
id="grok.build",
|
||||
display_name="GrokBuild",
|
||||
model_family="grok",
|
||||
used_percent=1.0,
|
||||
remaining_percent=99.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
status="healthy",
|
||||
)
|
||||
b_frequent = QuotaBucket(
|
||||
id="grok.frequent_tasks",
|
||||
display_name="Частые задачи",
|
||||
model_family="grok",
|
||||
used_absolute=0,
|
||||
remaining_absolute=10,
|
||||
used_absolute=None,
|
||||
remaining_absolute=None,
|
||||
limit_absolute=10,
|
||||
remaining_percent=100.0,
|
||||
status="healthy",
|
||||
)
|
||||
b_normal = QuotaBucket(
|
||||
id="grok.normal_tasks",
|
||||
display_name="Обычные задачи",
|
||||
model_family="grok",
|
||||
used_absolute=0,
|
||||
remaining_absolute=30,
|
||||
used_absolute=None,
|
||||
remaining_absolute=None,
|
||||
limit_absolute=30,
|
||||
remaining_percent=100.0,
|
||||
status="healthy",
|
||||
)
|
||||
|
||||
|
|
@ -504,7 +501,7 @@ class AccountQuotaService:
|
|||
provider="grok",
|
||||
buckets=[b_weekly, b_chat, b_build, b_frequent, b_normal],
|
||||
fetched_at=now,
|
||||
source="xai_task_usage_api",
|
||||
source="baseline",
|
||||
)
|
||||
|
||||
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
|
||||
|
|
@ -513,8 +510,8 @@ class AccountQuotaService:
|
|||
b = QuotaBucket(
|
||||
id=f"{provider}.default",
|
||||
display_name="Основная квота",
|
||||
used_percent=0.0,
|
||||
remaining_percent=100.0,
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
status="healthy",
|
||||
)
|
||||
return QuotaSnapshot(
|
||||
|
|
|
|||
|
|
@ -581,20 +581,32 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
def _init_codex_oauth(self):
|
||||
try:
|
||||
from antigravity_provider.router.codex_oauth import start_codex_oauth
|
||||
from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session
|
||||
session_id, url, code = start_codex_oauth(self.target_slot)
|
||||
self.codex_session_id = session_id
|
||||
self.codex_url = url
|
||||
self.codex_user_code = code
|
||||
|
||||
session = get_codex_oauth_session(session_id)
|
||||
if not code or (session and session.status == "failed"):
|
||||
err_msg = getattr(session, "error_msg", None) or "Не удалось получить код авторизации"
|
||||
self.codex_status_lbl.configure(text=f"❌ {err_msg}", text_color=Theme.STATUS_ERROR)
|
||||
return
|
||||
|
||||
self.codex_url_entry.delete(0, "end")
|
||||
self.codex_url_entry.insert(0, url)
|
||||
self.codex_code_lbl.configure(text=code)
|
||||
|
||||
self.codex_status_lbl.configure(
|
||||
text=f"Ожидание подтверждения кода {code} в браузере...",
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
)
|
||||
if getattr(session, "is_dev_mode", False):
|
||||
self.codex_status_lbl.configure(
|
||||
text=f"⚠️ ТЕСТОВЫЙ РЕЖИМ (HERMES_HUB_DEV_MODE): Код {code}",
|
||||
text_color=Theme.STATUS_WARNING,
|
||||
)
|
||||
else:
|
||||
self.codex_status_lbl.configure(
|
||||
text=f"Ожидание подтверждения кода {code} в браузере...",
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
)
|
||||
|
||||
self._polling_active = True
|
||||
threading.Thread(target=self._poll_codex_oauth, daemon=True).start()
|
||||
|
|
@ -1000,20 +1012,32 @@ class AddAccountWizard(HubModal):
|
|||
|
||||
def _init_grok_oauth(self):
|
||||
try:
|
||||
from antigravity_provider.router.grok_oauth import start_grok_oauth
|
||||
from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session
|
||||
session_id, url, code = start_grok_oauth(self.target_slot)
|
||||
self.grok_session_id = session_id
|
||||
self.grok_url = url
|
||||
self.grok_user_code = code
|
||||
|
||||
session = get_grok_oauth_session(session_id)
|
||||
if not code or (session and session.status == "failed"):
|
||||
err_msg = getattr(session, "error_msg", None) or "Не удалось получить код авторизации"
|
||||
self.grok_status_lbl.configure(text=f"❌ {err_msg}", text_color=Theme.STATUS_ERROR)
|
||||
return
|
||||
|
||||
self.grok_url_entry.delete(0, "end")
|
||||
self.grok_url_entry.insert(0, url)
|
||||
self.grok_code_lbl.configure(text=code)
|
||||
|
||||
self.grok_status_lbl.configure(
|
||||
text=f"Ожидание подтверждения кода {code} в браузере...",
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
)
|
||||
if getattr(session, "is_dev_mode", False):
|
||||
self.grok_status_lbl.configure(
|
||||
text=f"⚠️ ТЕСТОВЫЙ РЕЖИМ (HERMES_HUB_DEV_MODE): Код {code}",
|
||||
text_color=Theme.STATUS_WARNING,
|
||||
)
|
||||
else:
|
||||
self.grok_status_lbl.configure(
|
||||
text=f"Ожидание подтверждения кода {code} в браузере...",
|
||||
text_color=Theme.TEXT_SECONDARY,
|
||||
)
|
||||
|
||||
self._polling_active = True
|
||||
threading.Thread(target=self._poll_grok_oauth, daemon=True).start()
|
||||
|
|
|
|||
|
|
@ -169,21 +169,25 @@ class AccountCardWidget(HubCard):
|
|||
for child in self.quota_box.winfo_children():
|
||||
child.destroy()
|
||||
|
||||
is_estimated = getattr(snap, "is_estimated", True) if snap else True
|
||||
if snap and getattr(snap, "buckets", None):
|
||||
for b in snap.buckets[:4]:
|
||||
brow = ctk.CTkFrame(self.quota_box, fg_color="transparent")
|
||||
brow.pack(fill="x", padx=8, pady=2)
|
||||
ctk.CTkLabel(brow, text=b.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
||||
disp_name = f"{b.display_name} (оценка)" if is_estimated else b.display_name
|
||||
ctk.CTkLabel(brow, text=disp_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
|
||||
|
||||
b_status_col = Theme.STATUS_HEALTHY if b.status == "healthy" else (Theme.STATUS_WARNING if b.status == "warning" else Theme.STATUS_ERROR)
|
||||
reset_text = f" ({b.formatted_reset()})" if b.formatted_reset() else ""
|
||||
rem_text = f"{b.formatted_remaining()}{reset_text}"
|
||||
ctk.CTkLabel(brow, text=rem_text, font=Theme.font_micro(), text_color=b_status_col).pack(side="right")
|
||||
else:
|
||||
ctk.CTkLabel(self.quota_box, text="Квота: доступна", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(padx=8, pady=4)
|
||||
ctk.CTkLabel(self.quota_box, text="Квота: доступна (оценка)", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(padx=8, pady=4)
|
||||
|
||||
# Freshness label
|
||||
fresh_lbl_text = snap.freshness_label() if (snap and hasattr(snap, "freshness_label")) else "Обновлено: недавно"
|
||||
if is_estimated:
|
||||
fresh_lbl_text += " • оценка"
|
||||
self.fresh_lbl.configure(text=fresh_lbl_text)
|
||||
|
||||
|
||||
|
|
|
|||
147
tests/test_data_truthfulness_and_oauth_security.py
Normal file
147
tests/test_data_truthfulness_and_oauth_security.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Tests for Data Truthfulness (P0-1) and OAuth Security Fail-Closed Invariants (P0-2).
|
||||
|
||||
Verifies:
|
||||
1. Quota snapshots without live provider metrics are honestly marked as source='baseline' (never '*_api').
|
||||
2. Quota buckets do not return fabricated percentages when unmeasured by API.
|
||||
3. Quota snapshots report is_estimated=True.
|
||||
4. Codex and Grok OAuth device flows fail immediately on network errors when not in DEV_MODE.
|
||||
5. Zero background polling is launched on device flow initialization failure.
|
||||
6. Fake code generation is strictly gated behind HERMES_HUB_DEV_MODE=1.
|
||||
7. Claude token exchange returns failure on invalid codes rather than silently accepting them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||
from antigravity_provider.router.account_identity import QuotaSnapshot, QuotaBucket
|
||||
from antigravity_provider.router.codex_oauth import CodexOAuthSession
|
||||
from antigravity_provider.router.grok_oauth import GrokOAuthSession
|
||||
from antigravity_provider.router.claude_oauth import ClaudeOAuthSession
|
||||
|
||||
|
||||
# ── TEST P0-1: Data Truthfulness in Quota Collection ──
|
||||
|
||||
def test_quota_collector_never_fakes_api_source_without_network():
|
||||
"""P0-1: Quota snapshots without live network endpoints must NOT claim '*_api' sources."""
|
||||
service = AccountQuotaService.get()
|
||||
|
||||
providers = ["antigravity", "openai-codex", "opencode-go", "claude", "grok"]
|
||||
for prov in providers:
|
||||
with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"token": "mock_tok"}):
|
||||
snap = service.fetch_account_quota(prov, f"{prov}-slot-1", force=True)
|
||||
|
||||
# Invariant: source must be 'baseline' or 'estimated', never '*_api'
|
||||
assert not snap.source.endswith("_api"), f"Provider {prov} falsely claimed API source '{snap.source}'"
|
||||
assert snap.source in ("baseline", "estimated", "unconfigured", "runtime_event")
|
||||
assert snap.is_estimated is True
|
||||
|
||||
# Invariant: no fabricated non-zero used percentages
|
||||
for b in snap.buckets:
|
||||
if b.status == "healthy":
|
||||
assert b.used_percent is None or b.used_percent == 0.0, (
|
||||
f"Bucket {b.id} returned fabricated used_percent {b.used_percent}"
|
||||
)
|
||||
|
||||
|
||||
def test_quota_bucket_formatted_remaining_honesty():
|
||||
"""P0-1: Bucket formatted_remaining returns honest availability when percentages are None."""
|
||||
b = QuotaBucket(
|
||||
id="test.bucket",
|
||||
display_name="Test Bucket",
|
||||
used_percent=None,
|
||||
remaining_percent=None,
|
||||
status="healthy",
|
||||
)
|
||||
assert b.formatted_remaining() == "Доступна"
|
||||
assert b.status == "healthy"
|
||||
|
||||
|
||||
# ── TEST P0-2: OAuth Fail-Closed & DEV_MODE Gating ──
|
||||
|
||||
def test_codex_oauth_fails_immediately_on_network_error(monkeypatch):
|
||||
"""P0-2: Codex device flow must fail immediately on network error without HERMES_HUB_DEV_MODE."""
|
||||
monkeypatch.delenv("HERMES_HUB_DEV_MODE", raising=False)
|
||||
|
||||
session = CodexOAuthSession("codex-slot-1")
|
||||
|
||||
with patch("antigravity_provider.router.codex_oauth._post_json", side_effect=ConnectionError("DNS failure")):
|
||||
url, code = session.start()
|
||||
|
||||
# Must fail immediately
|
||||
assert url == ""
|
||||
assert code == ""
|
||||
assert session.status == "failed"
|
||||
assert session.error_msg is not None
|
||||
assert "DNS failure" in session.error_msg or "Не удалось подключиться" in session.error_msg
|
||||
# Must NOT launch background polling thread
|
||||
assert session.poll_thread is None
|
||||
assert session.user_code is None
|
||||
assert session.is_dev_mode is False
|
||||
|
||||
|
||||
def test_codex_oauth_dev_mode_fallback(monkeypatch):
|
||||
"""P0-2: Codex device flow allows local mock session ONLY when HERMES_HUB_DEV_MODE=1."""
|
||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||
|
||||
session = CodexOAuthSession("codex-slot-1")
|
||||
|
||||
with patch("antigravity_provider.router.codex_oauth._post_json", side_effect=ConnectionError("Offline")):
|
||||
url, code = session.start()
|
||||
|
||||
assert code.startswith("CDX-")
|
||||
assert session.status == "pending"
|
||||
assert session.is_dev_mode is True
|
||||
assert session.poll_thread is not None
|
||||
session.cancel()
|
||||
|
||||
|
||||
def test_grok_oauth_fails_immediately_on_network_error(monkeypatch):
|
||||
"""P0-2: Grok device flow must fail immediately on network error without HERMES_HUB_DEV_MODE."""
|
||||
monkeypatch.delenv("HERMES_HUB_DEV_MODE", raising=False)
|
||||
|
||||
session = GrokOAuthSession("grok-slot-1")
|
||||
|
||||
with patch("antigravity_provider.router.grok_oauth._post_form", side_effect=TimeoutError("xAI unreachable")):
|
||||
url, code = session.start()
|
||||
|
||||
assert url == ""
|
||||
assert code == ""
|
||||
assert session.status == "failed"
|
||||
assert session.error_msg is not None
|
||||
assert "xAI unreachable" in session.error_msg or "Не удалось подключиться" in session.error_msg
|
||||
assert session.poll_thread is None
|
||||
assert session.user_code is None
|
||||
assert session.is_dev_mode is False
|
||||
|
||||
|
||||
def test_grok_oauth_dev_mode_fallback(monkeypatch):
|
||||
"""P0-2: Grok device flow allows local mock session ONLY when HERMES_HUB_DEV_MODE=1."""
|
||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||
|
||||
session = GrokOAuthSession("grok-slot-1")
|
||||
|
||||
with patch("antigravity_provider.router.grok_oauth._post_form", side_effect=TimeoutError("Offline")):
|
||||
url, code = session.start()
|
||||
|
||||
assert code.startswith("GRK-")
|
||||
assert session.status == "pending"
|
||||
assert session.is_dev_mode is True
|
||||
assert session.poll_thread is not None
|
||||
session.cancel()
|
||||
|
||||
|
||||
def test_claude_oauth_rejects_invalid_code_on_network_failure(monkeypatch):
|
||||
"""P0-2: Claude OAuth rejects invalid raw code when token endpoint fails."""
|
||||
monkeypatch.delenv("HERMES_HUB_DEV_MODE", raising=False)
|
||||
|
||||
session = ClaudeOAuthSession("claude-slot-1")
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=ConnectionRefusedError("Endpoint down")):
|
||||
ok, msg = session.handle_auth_code("fake_temporary_auth_code_1234567890")
|
||||
|
||||
assert ok is False
|
||||
assert session.status == "failed"
|
||||
assert "Ошибка обмена кода" in msg
|
||||
Loading…
Reference in a new issue