feat(web): вход Antigravity и Claude из браузера на любой машине
Мастер подключения писал, что на сервере без экрана вход «через веб-интерфейс невозможен», и отправлял в консоль по 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 <noreply@anthropic.com>
This commit is contained in:
parent
1d5e44ab3c
commit
3287565773
7 changed files with 359 additions and 42 deletions
|
|
@ -120,6 +120,18 @@ test
|
||||||
|
|
||||||
`poll_device_auth` — `data: {"provider", "session_id"}`. `ok: true` со `status: "pending"` означает ожидание подтверждения; `status: "completed"` — аккаунт подключён; `ok: false` — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос.
|
`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`
|
### `GET /api/health`
|
||||||
|
|
||||||
`{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.
|
`{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,98 @@ class ActionExecutor:
|
||||||
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —
|
# 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']:
|
if action in ['oauth', 'account_details', 'agent_settings', 'open_routing']:
|
||||||
return {'ok': True, 'message': 'Навигация'}
|
return {'ok': True, 'message': 'Навигация'}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,13 @@ class _ProfileOAuthServer(HTTPServer):
|
||||||
super().__init__(server_address, _ProfileOAuthCallbackHandler)
|
super().__init__(server_address, _ProfileOAuthCallbackHandler)
|
||||||
|
|
||||||
|
|
||||||
|
# Окно жизни слушателя. Прежние 5 минут рассчитаны на браузер той же машины.
|
||||||
|
# При входе с другого ПК владелец успевает только открыть ссылку: надо войти в
|
||||||
|
# аккаунт, подтвердить доступ и перенести адрес обратно. 20 минут — значение,
|
||||||
|
# проверенное на практике при входе по слотам.
|
||||||
|
_LISTENER_TTL_SEC = 1200
|
||||||
|
|
||||||
|
|
||||||
class ProfileOAuthSession:
|
class ProfileOAuthSession:
|
||||||
"""Manages a single interactive OAuth flow for linking an Antigravity profile."""
|
"""Manages a single interactive OAuth flow for linking an Antigravity profile."""
|
||||||
|
|
||||||
|
|
@ -161,7 +168,7 @@ class ProfileOAuthSession:
|
||||||
|
|
||||||
def _serve():
|
def _serve():
|
||||||
try:
|
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:
|
if self.server:
|
||||||
try:
|
try:
|
||||||
self.server.handle_request()
|
self.server.handle_request()
|
||||||
|
|
@ -172,7 +179,7 @@ class ProfileOAuthSession:
|
||||||
|
|
||||||
if self.status == "pending" and not self._is_completed:
|
if self.status == "pending" and not self._is_completed:
|
||||||
self.status = "timeout"
|
self.status = "timeout"
|
||||||
self.error_msg = "Срок действия ссылки авторизации истёк (таймаут 5 минут)"
|
self.error_msg = "Слушатель закрыт по таймауту; адрес возврата всё ещё можно вставить вручную"
|
||||||
logger.info("OAUTH callback server stopped reason=timeout")
|
logger.info("OAUTH callback server stopped reason=timeout")
|
||||||
except Exception as loop_err:
|
except Exception as loop_err:
|
||||||
logger.error("OAUTH listener exception: %s: %s", type(loop_err).__name__, 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)
|
logger.warning("OAuth error from provider (source=%s, error=%s)", source, error)
|
||||||
return False, self.error_msg
|
return False, self.error_msg
|
||||||
|
|
||||||
|
# Ошибки разбора при ручной вставке НЕ завершают сессию: владелец
|
||||||
|
# переносит адрес между машинами и легко промахивается — скопировал
|
||||||
|
# не ту вкладку, обрезал строку. Прежде первая же опечатка ставила
|
||||||
|
# status="failed", и вход приходилось начинать заново, хотя ссылка
|
||||||
|
# оставалась годной. Отказ провайдера ниже по-прежнему конечный.
|
||||||
|
_recoverable = source == "manual"
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
|
msg = "Код авторизации отсутствует в адресе. Скопируйте строку целиком из адресной строки браузера."
|
||||||
|
if not _recoverable:
|
||||||
self.status = "failed"
|
self.status = "failed"
|
||||||
self.error_msg = "Код авторизации отсутствует в callback URL"
|
self.error_msg = msg
|
||||||
logger.warning("OAuth code missing in callback (source=%s)", source)
|
logger.warning("OAuth code missing in callback (source=%s)", source)
|
||||||
return False, self.error_msg
|
return False, msg
|
||||||
|
|
||||||
if state != self.state:
|
if state != self.state:
|
||||||
|
msg = "Адрес относится к другой или устаревшей сессии входа. Возьмите адрес из вкладки, открытой по текущей ссылке."
|
||||||
|
if not _recoverable:
|
||||||
self.status = "failed"
|
self.status = "failed"
|
||||||
self.error_msg = "Несовпадение параметра state. Callback относится к другой или устаревшей сессии."
|
self.error_msg = msg
|
||||||
logger.warning("OAuth state validation failed (source=%s)", source)
|
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)
|
logger.info("OAuth state validated (source=%s)", source)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,25 @@ def health_check():
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
|
# Antigravity и Claude раньше стояли здесь как supported: False с
|
||||||
|
# советом идти в десктоп или пробрасывать порты. Это было неверно:
|
||||||
|
# ProfileOAuthSession.handle_manual_callback_url и
|
||||||
|
# ClaudeOAuthSession.handle_auth_code принимают вставленное вручную
|
||||||
|
# значение, поэтому браузер нужен где угодно, а не на машине с Hub.
|
||||||
"auth_flows": {
|
"auth_flows": {
|
||||||
"openai-codex": {"supported": True, "reason": "device-code"},
|
"openai-codex": {"supported": True, "reason": "device-code"},
|
||||||
"grok": {"supported": True, "reason": "device-code"},
|
"grok": {"supported": True, "reason": "device-code"},
|
||||||
"opencode-go": {"supported": True, "reason": "token"},
|
"opencode-go": {"supported": True, "reason": "token"},
|
||||||
"antigravity": {"supported": False, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"},
|
"antigravity": {
|
||||||
"claude": {"supported": False, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"}
|
"supported": True,
|
||||||
|
"reason": "redirect-url-paste",
|
||||||
|
"hint": "Откройте ссылку в любом браузере и верните адрес из адресной строки",
|
||||||
|
},
|
||||||
|
"claude": {
|
||||||
|
"supported": True,
|
||||||
|
"reason": "code-paste",
|
||||||
|
"hint": "Откройте ссылку в любом браузере и верните показанный код",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1612,39 +1612,28 @@ function showWizardStep2(providerId) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
setTimeout(() => startDeviceAuth(providerId), 0);
|
setTimeout(() => startDeviceAuth(providerId), 0);
|
||||||
} else if (providerId === 'antigravity') {
|
} else if (providerId === 'antigravity' || providerId === 'claude') {
|
||||||
|
// Раньше здесь стояла заглушка: «авторизация через веб-интерфейс
|
||||||
|
// невозможна», со ссылкой на SSH и перенос каталога профилей. Это было
|
||||||
|
// неверно — сервер умеет принять вставленное вручную значение, поэтому
|
||||||
|
// браузер нужен ГДЕ УГОДНО, а не на машине с Hub.
|
||||||
|
const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude';
|
||||||
bodyHtml = `
|
bodyHtml = `
|
||||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
||||||
Шаг 2 из 3: Авторизация Google Antigravity
|
Шаг 2 из 3: Авторизация ${providerName}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-feedback info" style="margin-bottom:14px;">
|
<div style="margin-bottom:10px;">
|
||||||
<strong>Для серверов (Headless режим):</strong><br>
|
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
|
||||||
Провайдер Antigravity требует интерактивного входа через консоль <code>agy</code>. Авторизация напрямую через веб-интерфейс невозможна.
|
<select class="input-text" style="width:100%;" id="wiz-redirect-slot">${buildSlotOptions(providerId)}</select>
|
||||||
<div style="margin-top:6px;">
|
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
||||||
<strong>Что делать:</strong><br>
|
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
|
||||||
1. Зайти по SSH на сервер и выполнить вход в консоли, подставив каталог профиля:<br>
|
|
||||||
<code>python -c "from antigravity_provider.agy_subprocess import launch_native_agy_login as L; L('ag-w1').wait()"</code><br>
|
|
||||||
2. ИЛИ авторизоваться на локальном ПК и перенести директорию <code>~/.hermes/agy_profiles</code> на сервер.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
<div style="margin-bottom:10px;">
|
||||||
} else if (providerId === 'claude') {
|
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
|
||||||
bodyHtml = `
|
|
||||||
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
|
|
||||||
Шаг 2 из 3: Авторизация Claude
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-feedback info" style="margin-bottom:14px;">
|
<div id="redirect-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
|
||||||
<strong>Для серверов (Headless режим):</strong><br>
|
<div style="color:var(--text-secondary);">Выберите слот и нажмите «Получить ссылку».</div>
|
||||||
Провайдер Claude использует локальный OAuth redirect (localhost). На сервере без браузера редирект придёт на локальную машину.
|
|
||||||
<div style="margin-top:6px;">
|
|
||||||
<strong>Альтернативные действия:</strong><br>
|
|
||||||
1. Использовать API Key напрямую.<br>
|
|
||||||
2. Пробросить порт через SSH: <code>ssh -L 8085:localhost:8085 user@server</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom:12px;">
|
|
||||||
<label style="display:block; font-weight:600; margin-bottom:4px;">Впишите API Key / Сгенерированный токен:</label>
|
|
||||||
<input type="password" class="input-text" style="width:100%;" id="wiz-token-input" placeholder="Вставьте токен или нажмите Далее...">
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
|
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
|
||||||
|
|
@ -1974,6 +1963,8 @@ function showModal() {
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
_openAccountModalProfile = null;
|
_openAccountModalProfile = null;
|
||||||
stopDeviceAuthPolling();
|
stopDeviceAuthPolling();
|
||||||
|
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
|
||||||
|
stopRedirectAuthPolling();
|
||||||
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2096,6 +2087,164 @@ async function pollDeviceAuth(providerId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Список слотов провайдера из снимка: занятые помечены, свободные идут первыми.
|
||||||
|
function buildSlotOptions(providerId) {
|
||||||
|
const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || [];
|
||||||
|
if (!profiles.length) {
|
||||||
|
return '<option value="">Список слотов ещё не получен</option>';
|
||||||
|
}
|
||||||
|
const free = [];
|
||||||
|
const used = [];
|
||||||
|
profiles.forEach((p) => {
|
||||||
|
const isFree = p.health_state === 'not_configured';
|
||||||
|
const who = p.email || p.account_identity || '';
|
||||||
|
const label = isFree
|
||||||
|
? `${p.profile_id} — свободен`
|
||||||
|
: `${p.profile_id} — занят${who ? ': ' + who : ''}`;
|
||||||
|
(isFree ? free : used).push(
|
||||||
|
`<option value="${escapeHtml(p.profile_id)}">${escapeHtml(label)}</option>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return free.concat(used).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
let _redirectAuthTimer = null;
|
||||||
|
|
||||||
|
function stopRedirectAuthPolling() {
|
||||||
|
if (_redirectAuthTimer) {
|
||||||
|
clearInterval(_redirectAuthTimer);
|
||||||
|
_redirectAuthTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вход по ссылке для Antigravity и Claude.
|
||||||
|
//
|
||||||
|
// Смысл: браузер не обязан быть на той машине, где работает Hub. Владелец
|
||||||
|
// открывает ссылку у себя, подтверждает доступ и возвращает результат сюда.
|
||||||
|
// Antigravity кладёт код в адресную строку (браузер при этом покажет ошибку
|
||||||
|
// соединения, если слушатель на другой машине — это нормально, адрес всё
|
||||||
|
// равно годен). Claude показывает код прямо на странице.
|
||||||
|
async function startRedirectAuth(providerId) {
|
||||||
|
stopRedirectAuthPolling();
|
||||||
|
const box = document.getElementById('redirect-auth-box');
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
// Слот выбирает владелец, а не догадка сервера.
|
||||||
|
//
|
||||||
|
// find_free_slot определяет занятость по файлу учётных данных, но agy на
|
||||||
|
// Windows хранит их в keyring — файла нет ни у одного слота, поэтому все
|
||||||
|
// десять считаются свободными и всегда возвращается первый, ag-orch-fallback.
|
||||||
|
// Вход в него затёр бы работающий аккаунт. Список ниже строится из снимка,
|
||||||
|
// который знает настоящее состояние, и показывает, что занято.
|
||||||
|
const slot = document.getElementById('wiz-redirect-slot');
|
||||||
|
const chosen = slot ? slot.value : '';
|
||||||
|
|
||||||
|
const res = await executeAction('start_redirect_auth', {
|
||||||
|
provider: providerId,
|
||||||
|
profile_id: chosen || undefined,
|
||||||
|
});
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = res.data || {};
|
||||||
|
window._wiz_redirect_session = d.session_id;
|
||||||
|
window._wiz_redirect_provider = providerId;
|
||||||
|
|
||||||
|
const pastesUrl = d.paste_kind !== 'code';
|
||||||
|
const label = pastesUrl
|
||||||
|
? 'Вставьте адрес из адресной строки браузера целиком:'
|
||||||
|
: 'Вставьте код, показанный на странице:';
|
||||||
|
const placeholder = pastesUrl ? 'http://127.0.0.1:…/oauth-callback?code=…' : 'Код со страницы провайдера';
|
||||||
|
|
||||||
|
const localNote = pastesUrl && d.redirect_uri
|
||||||
|
? `<div style="font-size:12px; color:var(--text-muted); margin-top:8px;">
|
||||||
|
После подтверждения браузер уйдёт на <code>${escapeHtml(d.redirect_uri)}</code>.
|
||||||
|
Если Hub работает на другой машине, страница не откроется — это ожидаемо.
|
||||||
|
Нужен сам адрес из строки браузера, а не содержимое страницы.
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
<div style="font-weight:700; margin-bottom:6px;">1. Откройте ссылку — на любой машине, где есть браузер:</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:12px;">
|
||||||
|
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-url" value="${escapeHtml(d.url || '')}" readonly>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="window.open(document.getElementById('wiz-redirect-url').value, '_blank')">Открыть</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="navigator.clipboard.writeText(document.getElementById('wiz-redirect-url').value); showToast('Ссылка скопирована', 'success');">Копировать</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:700; margin-bottom:6px;">2. ${escapeHtml(label)}</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:6px;">
|
||||||
|
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-paste" placeholder="${escapeHtml(placeholder)}">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="submitRedirectCallback()">Завершить вход</button>
|
||||||
|
</div>
|
||||||
|
${localNote}
|
||||||
|
<div id="redirect-auth-status" style="font-size:12px; color:var(--text-muted); margin-top:10px;">
|
||||||
|
Слот: ${escapeHtml(d.profile_id || '—')}. Ссылка действует 20 минут.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Если браузер открыт на этой же машине, слушатель поймает возврат сам —
|
||||||
|
// тогда вставлять ничего не придётся.
|
||||||
|
_redirectAuthTimer = setInterval(pollRedirectAuth, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRedirectCallback() {
|
||||||
|
const input = document.getElementById('wiz-redirect-paste');
|
||||||
|
const status = document.getElementById('redirect-auth-status');
|
||||||
|
if (!input || !status) return;
|
||||||
|
|
||||||
|
const value = (input.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
status.innerHTML = '<span style="color:var(--status-warning);">Поле пустое — вставьте значение из браузера.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.innerHTML = 'Проверяем…';
|
||||||
|
const res = await executeAction('submit_redirect_callback', {
|
||||||
|
session_id: window._wiz_redirect_session,
|
||||||
|
provider: window._wiz_redirect_provider,
|
||||||
|
callback_url: value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.ok) {
|
||||||
|
stopRedirectAuthPolling();
|
||||||
|
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
|
||||||
|
showToast('Аккаунт подключён', 'success');
|
||||||
|
fetchSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Промах при вставке не заканчивает сессию: ссылка ещё годна, можно
|
||||||
|
// вставить снова. Поэтому опрос не останавливаем.
|
||||||
|
status.innerHTML = `<span style="color:var(--status-error);">${escapeHtml((res && res.message) || 'Не удалось завершить вход')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollRedirectAuth() {
|
||||||
|
const status = document.getElementById('redirect-auth-status');
|
||||||
|
if (!status) {
|
||||||
|
stopRedirectAuthPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await executeAction('poll_redirect_auth', {
|
||||||
|
session_id: window._wiz_redirect_session,
|
||||||
|
provider: window._wiz_redirect_provider,
|
||||||
|
});
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
|
if (res.ok && (res.data || {}).status === 'completed') {
|
||||||
|
stopRedirectAuthPolling();
|
||||||
|
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
|
||||||
|
showToast('Аккаунт подключён', 'success');
|
||||||
|
fetchSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
// Конечный отказ провайдера — например, доступ отклонён.
|
||||||
|
stopRedirectAuthPolling();
|
||||||
|
status.innerHTML = `<span style="color:var(--status-error); font-weight:600;">${escapeHtml(res.message || 'Авторизация не завершена')}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteCredentials(profileId) {
|
async function handleDeleteCredentials(profileId) {
|
||||||
// Подтверждение обязательно: действие необратимо, аккаунт придётся
|
// Подтверждение обязательно: действие необратимо, аккаунт придётся
|
||||||
// подключать заново.
|
// подключать заново.
|
||||||
|
|
|
||||||
|
|
@ -125,11 +125,23 @@ def test_c_state_mismatch(tmp_path, monkeypatch):
|
||||||
pasted_url = f"http://127.0.0.1:{port}/oauth-callback?state=wrong_mismatched_state&code=mock_code"
|
pasted_url = f"http://127.0.0.1:{port}/oauth-callback?state=wrong_mismatched_state&code=mock_code"
|
||||||
ok, msg = session.handle_manual_callback_url(pasted_url)
|
ok, msg = session.handle_manual_callback_url(pasted_url)
|
||||||
|
|
||||||
|
# Главное — свойство безопасности: чужой state отвергнут и обмена
|
||||||
|
# кода на токены не произошло.
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert "Несовпадение" in msg or "state" in msg
|
assert "сессии" in msg or "state" in msg
|
||||||
assert session.status == "failed"
|
|
||||||
assert mock_exchange.call_count == 0
|
assert mock_exchange.call_count == 0
|
||||||
|
|
||||||
|
# А вот сессию промах завершать НЕ должен. Владелец переносит адрес
|
||||||
|
# между машинами руками и легко берёт не ту вкладку; прежде первая же
|
||||||
|
# ошибка ставила status="failed", и вход приходилось начинать заново,
|
||||||
|
# хотя ссылка оставалась годной. Проверяем, что после промаха верная
|
||||||
|
# вставка по-прежнему доходит до обмена кода.
|
||||||
|
assert session.status == "pending"
|
||||||
|
|
||||||
|
good_url = f"http://127.0.0.1:{port}/oauth-callback?state={session.state}&code=mock_code"
|
||||||
|
session.handle_manual_callback_url(good_url)
|
||||||
|
assert mock_exchange.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_d_oauth_error_callback(tmp_path, monkeypatch):
|
def test_d_oauth_error_callback(tmp_path, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,30 @@ def test_headless_server_auth_matrix():
|
||||||
"настоящий приходит от провайдера в verification_uri"
|
"настоящий приходит от провайдера в verification_uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Честность про сервер без экрана должна остаться.
|
# Раньше здесь требовалось слово "Headless": мастер писал, что на сервере
|
||||||
assert "Headless" in app_js, "нет предупреждения про сервер без экрана"
|
# без экрана вход невозможен, и отправлял в консоль по SSH. Утверждение
|
||||||
assert "agy" in app_js, "не сказано, что вход идёт через консоль agy"
|
# оказалось ложным — сервер принимает вставленное вручную значение
|
||||||
|
# (handle_manual_callback_url и handle_auth_code), поэтому браузер нужен
|
||||||
|
# где угодно, а не на машине с Hub. Требовать это предупреждение значило
|
||||||
|
# защищать заглушку, как прежде защищался адрес x.ai/device.
|
||||||
|
# Проверяем текст, который видит владелец, а не комментарии в коде:
|
||||||
|
# история дефекта описана рядом и содержит то же слово.
|
||||||
|
for line in app_js.splitlines():
|
||||||
|
if line.lstrip().startswith("//"):
|
||||||
|
continue
|
||||||
|
assert "невозможна" not in line, (
|
||||||
|
"вернулось утверждение, что вход через веб невозможен; "
|
||||||
|
"вход по вставленной ссылке поддержан сервером"
|
||||||
|
)
|
||||||
|
for needed in ("startRedirectAuth", "submit_redirect_callback"):
|
||||||
|
assert needed in app_js, f"нет настоящего потока входа по ссылке: {needed}"
|
||||||
|
# Требование упоминать консоль agy — остаток той же заглушки: вход больше
|
||||||
|
# не идёт через консоль, поэтому отсылать к ней значит вводить в
|
||||||
|
# заблуждение. Вместо этого проверяем, что владельцу сказано главное —
|
||||||
|
# ссылку можно открыть на любой машине.
|
||||||
|
assert "на любой машине" in app_js, (
|
||||||
|
"не сказано, что ссылку авторизации можно открыть на другой машине"
|
||||||
|
)
|
||||||
assert "launcher/main.py" not in app_js, "инструкция ведёт на несуществующий файл"
|
assert "launcher/main.py" not in app_js, "инструкция ведёт на несуществующий файл"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue