From 3287565773473a008d5e091d769731461900be7c Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 25 Aug 2026 09:52:53 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B2=D1=85=D0=BE=D0=B4=20Antigra?= =?UTF-8?q?vity=20=D0=B8=20Claude=20=D0=B8=D0=B7=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=D0=B0=20=D0=BD=D0=B0=20=D0=BB=D1=8E?= =?UTF-8?q?=D0=B1=D0=BE=D0=B9=20=D0=BC=D0=B0=D1=88=D0=B8=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мастер подключения писал, что на сервере без экрана вход «через веб-интерфейс невозможен», и отправлял в консоль по SSH либо переносить каталог agy_profiles руками. GET /api/health отдавал для этих провайдеров жёстко вписанное supported: false. Утверждение оказалось ложным. В коде уже были ProfileOAuthSession.handle_manual_callback_url и ClaudeOAuthSession.handle_auth_code — оба принимают вставленное вручную значение и доводят обмен кода на токены. Наружу их просто не вывели. Браузер нужен где угодно, а не на машине с Hub: владелец открывает ссылку у себя и возвращает адрес из адресной строки. Добавлены действия start_redirect_auth, submit_redirect_callback, poll_redirect_auth, cancel_redirect_auth. auth_flows теперь отражает настоящие возможности, а не литерал. Обе заглушки в мастере заменены живым потоком; мёртвая ветка Claude с полем API Key удалена. Три дефекта, найденных при проверке исполнением: 1. Одна опечатка при вставке убивала сессию: handle_callback ставил status="failed" при отсутствии кода или чужом state, и вход приходилось начинать заново, хотя ссылка оставалась годной. Для ручного ввода такие ошибки больше не конечные; отказ провайдера конечен по-прежнему. 2. Окно слушателя в 5 минут рассчитано на браузер той же машины. При входе с другого ПК его не хватает: 20 минут — значение, проверенное на практике. 3. find_free_slot всегда возвращал ag-orch-fallback: занятость определяется по файлу учётных данных, а agy на Windows держит их в keyring, поэтому все десять слотов выглядят свободными. Вход затёр бы работающий аккаунт. Слот теперь выбирает владелец из списка, построенного по снапшоту, с пометкой, какие заняты и кем. Два теста закрепляли снятую заглушку: test_headless_server_auth_matrix требовал слов «Headless» и «agy» в интерфейсе, test_c_state_mismatch требовал status == "failed". Первый переведён на проверку настоящего потока, второй усилен: свойство безопасности (отказ без обмена кода) проверяется по-прежнему, и дополнительно проверено, что после промаха верная вставка доходит до обмена. Проверено вживую в браузере: список из 10 слотов с пометкой занятости, выбранный слот доходит до сервера, ссылка настоящая от accounts.google.com, поле вставки на месте. 431 passed, 2 skipped; ruff чисто. Co-Authored-By: Claude Opus 5 --- docs/web-api/CONTRACT.md | 12 ++ .../router/action_handler.py | 92 ++++++++ .../router/profile_oauth.py | 34 ++- src/antigravity_provider/router/web/server.py | 17 +- .../router/web/static/app.js | 203 +++++++++++++++--- tests/test_oauth_lifecycle.py | 16 +- tests/test_web_client_contract.py | 27 ++- 7 files changed, 359 insertions(+), 42 deletions(-) diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md index f6483c6..89aab49 100644 --- a/docs/web-api/CONTRACT.md +++ b/docs/web-api/CONTRACT.md @@ -120,6 +120,18 @@ test `poll_device_auth` — `data: {"provider", "session_id"}`. `ok: true` со `status: "pending"` означает ожидание подтверждения; `status: "completed"` — аккаунт подключён; `ok: false` — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос. +**`start_redirect_auth` / `submit_redirect_callback` / `poll_redirect_auth` / `cancel_redirect_auth`** (добавлено при разборе жалобы «не даёт добавлять аккаунты на линукс»): вход по ссылке для **Antigravity** и **Claude**. + +Раньше веб-мастер утверждал, что вход через веб «невозможен», и отправлял в консоль по SSH, а `GET /api/health` отдавал для этих двух провайдеров жёстко вписанное `supported: false`. Утверждение неверно: `ProfileOAuthSession.handle_manual_callback_url` и `ClaudeOAuthSession.handle_auth_code` принимают вставленное вручную значение. **Браузер нужен где угодно, а не на машине с Hub.** + +`start_redirect_auth` — `data: {"provider": "antigravity"|"claude", "profile_id"?}`. Возвращает `{"session_id", "url", "port", "redirect_uri", "profile_id", "paste_kind"}`. `paste_kind` — `"url"` (Antigravity кладёт код в адресную строку) или `"code"` (Claude показывает код на странице); подсказка в интерфейсе обязана различаться, иначе владелец ищет не то. **Ссылку выдаёт провайдер**, интерфейс не имеет права подставлять свою. + +`profile_id` передаёт **клиент**, а не подбирает сервер. `AutoAssigner.find_free_slot` определяет занятость по файлу учётных данных, но `agy` на Windows хранит их в keyring — файла нет ни у одного слота, поэтому все считаются свободными и всегда возвращается первый. Вход затёр бы работающий аккаунт. Слот выбирает владелец из списка, построенного по снапшоту. + +`submit_redirect_callback` — `data: {"session_id", "provider", "callback_url"}`. Ошибки разбора (нет кода, чужой `state`) **не завершают сессию**: владелец переносит значение между машинами руками и легко промахивается, а ссылка остаётся годной. Конечны только отказ провайдера и отмена. + +`poll_redirect_auth` — `data: {"session_id"}`. `status: "pending"` — ожидание; `"completed"` — подключено; `ok: false` — конечный отказ. Закрытие слушателя по таймауту (20 минут) **не** конечный отказ: вставить адрес вручную можно и после него. + ### `GET /api/health` `{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся. diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index fdaffc4..b4201e8 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -341,6 +341,98 @@ class ActionExecutor: # A25 внёс их в этот список, но в A24 они выполняют настоящую работу — # сохранение цепочки и назначение роли — обработчики ниже. Проглотив их # здесь, мы бы молча сломали перестановку блоков в маршрутизации. + # ── Вход по localhost-redirect (Antigravity) ────────────────── + # Раньше веб-мастер писал «авторизация через веб-интерфейс + # невозможна» и отправлял в консоль по SSH. Это неверно: + # ProfileOAuthSession.handle_manual_callback_url принимает адрес + # возврата, вставленный руками, и завершает обмен кода на токены. + # Значит браузер нужен ГДЕ УГОДНО, а не на той же машине: владелец + # открывает ссылку у себя, а адрес из строки браузера возвращает в Hub. + if action == 'start_redirect_auth': + provider = (data.get('provider') or '').strip().lower() + if provider in ('google-antigravity',): + provider = 'antigravity' + if provider not in ('antigravity', 'claude'): + return {'ok': False, 'message': f'Провайдер {provider} не использует вход по ссылке'} + slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider) + if not slot: + return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'} + try: + if provider == 'antigravity': + from antigravity_provider.router.profile_oauth import ( + get_oauth_session, + start_profile_oauth, + ) + + session_id, url, port = start_profile_oauth(slot) + redirect_uri = getattr(get_oauth_session(session_id), 'redirect_uri', '') + else: + from antigravity_provider.router.claude_oauth import start_claude_oauth + + session_id, url = start_claude_oauth(slot) + port, redirect_uri = 0, '' + except Exception as exc: + return {'ok': False, 'message': f'Не удалось начать авторизацию: {exc}'} + + return { + 'ok': True, + 'message': 'Ссылка авторизации получена', + 'data': { + 'session_id': session_id, + 'url': url, + 'port': port, + 'redirect_uri': redirect_uri, + 'profile_id': slot, + 'provider': provider, + # Antigravity возвращает код в адресной строке, Claude + # показывает его на странице — подсказка в интерфейсе + # должна отличаться, иначе владелец ищет не то. + 'paste_kind': 'url' if provider == 'antigravity' else 'code', + }, + } + + if action == 'submit_redirect_callback': + session_id = data.get('session_id') or '' + raw_value = data.get('callback_url') or data.get('url') or data.get('code') or '' + provider = (data.get('provider') or 'antigravity').strip().lower() + if provider == 'claude': + from antigravity_provider.router.claude_oauth import get_claude_oauth_session + + session = get_claude_oauth_session(session_id) + if not session: + return {'ok': False, 'message': 'Сессия авторизации не найдена или уже завершена'} + ok, msg = session.handle_auth_code(raw_value) + else: + from antigravity_provider.router.profile_oauth import get_oauth_session + + session = get_oauth_session(session_id) + if not session: + return {'ok': False, 'message': 'Сессия авторизации не найдена или уже завершена'} + ok, msg = session.handle_manual_callback_url(raw_value) + return {'ok': ok, 'message': msg} + + if action == 'poll_redirect_auth': + session_id = data.get('session_id') or '' + from antigravity_provider.router.profile_oauth import get_oauth_session + + session = get_oauth_session(session_id) + if not session: + return {'ok': False, 'message': 'Сессия авторизации не найдена'} + status = getattr(session, 'status', 'unknown') + if status == 'completed': + return {'ok': True, 'message': 'Аккаунт подключён', 'data': {'status': 'completed'}} + if status in ('failed', 'cancelled'): + return {'ok': False, 'message': session.error_msg or 'Авторизация не удалась'} + # timeout закрывает только слушатель: вставленный вручную адрес + # по-прежнему принимается, поэтому это не конечный отказ. + return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': 'pending'}} + + if action == 'cancel_redirect_auth': + from antigravity_provider.router.profile_oauth import cancel_oauth_session + + cancel_oauth_session(data.get('session_id') or '') + return {'ok': True, 'message': 'Авторизация отменена'} + if action in ['oauth', 'account_details', 'agent_settings', 'open_routing']: return {'ok': True, 'message': 'Навигация'} diff --git a/src/antigravity_provider/router/profile_oauth.py b/src/antigravity_provider/router/profile_oauth.py index 9f375e6..6b6ad91 100644 --- a/src/antigravity_provider/router/profile_oauth.py +++ b/src/antigravity_provider/router/profile_oauth.py @@ -105,6 +105,13 @@ class _ProfileOAuthServer(HTTPServer): super().__init__(server_address, _ProfileOAuthCallbackHandler) +# Окно жизни слушателя. Прежние 5 минут рассчитаны на браузер той же машины. +# При входе с другого ПК владелец успевает только открыть ссылку: надо войти в +# аккаунт, подтвердить доступ и перенести адрес обратно. 20 минут — значение, +# проверенное на практике при входе по слотам. +_LISTENER_TTL_SEC = 1200 + + class ProfileOAuthSession: """Manages a single interactive OAuth flow for linking an Antigravity profile.""" @@ -161,7 +168,7 @@ class ProfileOAuthSession: def _serve(): try: - while self.status == "pending" and time.time() - self.created_at < 300: + while self.status == "pending" and time.time() - self.created_at < _LISTENER_TTL_SEC: if self.server: try: self.server.handle_request() @@ -172,7 +179,7 @@ class ProfileOAuthSession: if self.status == "pending" and not self._is_completed: self.status = "timeout" - self.error_msg = "Срок действия ссылки авторизации истёк (таймаут 5 минут)" + self.error_msg = "Слушатель закрыт по таймауту; адрес возврата всё ещё можно вставить вручную" logger.info("OAUTH callback server stopped reason=timeout") except Exception as loop_err: logger.error("OAUTH listener exception: %s: %s", type(loop_err).__name__, loop_err) @@ -211,17 +218,28 @@ class ProfileOAuthSession: logger.warning("OAuth error from provider (source=%s, error=%s)", source, error) return False, self.error_msg + # Ошибки разбора при ручной вставке НЕ завершают сессию: владелец + # переносит адрес между машинами и легко промахивается — скопировал + # не ту вкладку, обрезал строку. Прежде первая же опечатка ставила + # status="failed", и вход приходилось начинать заново, хотя ссылка + # оставалась годной. Отказ провайдера ниже по-прежнему конечный. + _recoverable = source == "manual" + if not code: - self.status = "failed" - self.error_msg = "Код авторизации отсутствует в callback URL" + msg = "Код авторизации отсутствует в адресе. Скопируйте строку целиком из адресной строки браузера." + if not _recoverable: + self.status = "failed" + self.error_msg = msg logger.warning("OAuth code missing in callback (source=%s)", source) - return False, self.error_msg + return False, msg if state != self.state: - self.status = "failed" - self.error_msg = "Несовпадение параметра state. Callback относится к другой или устаревшей сессии." + msg = "Адрес относится к другой или устаревшей сессии входа. Возьмите адрес из вкладки, открытой по текущей ссылке." + if not _recoverable: + self.status = "failed" + self.error_msg = msg logger.warning("OAuth state validation failed (source=%s)", source) - return False, self.error_msg + return False, msg logger.info("OAuth state validated (source=%s)", source) diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 3acea70..7db9452 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -66,12 +66,25 @@ def health_check(): return { "ok": True, "version": __version__, + # Antigravity и Claude раньше стояли здесь как supported: False с + # советом идти в десктоп или пробрасывать порты. Это было неверно: + # ProfileOAuthSession.handle_manual_callback_url и + # ClaudeOAuthSession.handle_auth_code принимают вставленное вручную + # значение, поэтому браузер нужен где угодно, а не на машине с Hub. "auth_flows": { "openai-codex": {"supported": True, "reason": "device-code"}, "grok": {"supported": True, "reason": "device-code"}, "opencode-go": {"supported": True, "reason": "token"}, - "antigravity": {"supported": False, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"}, - "claude": {"supported": False, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"} + "antigravity": { + "supported": True, + "reason": "redirect-url-paste", + "hint": "Откройте ссылку в любом браузере и верните адрес из адресной строки", + }, + "claude": { + "supported": True, + "reason": "code-paste", + "hint": "Откройте ссылку в любом браузере и верните показанный код", + }, } } diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index dd3e83f..e496292 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -1612,39 +1612,28 @@ function showWizardStep2(providerId) { `; setTimeout(() => startDeviceAuth(providerId), 0); - } else if (providerId === 'antigravity') { + } else if (providerId === 'antigravity' || providerId === 'claude') { + // Раньше здесь стояла заглушка: «авторизация через веб-интерфейс + // невозможна», со ссылкой на SSH и перенос каталога профилей. Это было + // неверно — сервер умеет принять вставленное вручную значение, поэтому + // браузер нужен ГДЕ УГОДНО, а не на машине с Hub. + const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude'; bodyHtml = `
- Шаг 2 из 3: Авторизация Google Antigravity + Шаг 2 из 3: Авторизация ${providerName}
-