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:
Hermes Team 2026-08-25 09:52:53 +07:00
parent 1d5e44ab3c
commit 3287565773
7 changed files with 359 additions and 42 deletions

View file

@ -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": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.

View file

@ -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': 'Навигация'}

View file

@ -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:
self.status = "failed" msg = "Код авторизации отсутствует в адресе. Скопируйте строку целиком из адресной строки браузера."
self.error_msg = "Код авторизации отсутствует в callback URL" if not _recoverable:
self.status = "failed"
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:
self.status = "failed" msg = "Адрес относится к другой или устаревшей сессии входа. Возьмите адрес из вкладки, открытой по текущей ссылке."
self.error_msg = "Несовпадение параметра state. Callback относится к другой или устаревшей сессии." if not _recoverable:
self.status = "failed"
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)

View file

@ -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": "Откройте ссылку в любом браузере и верните показанный код",
},
} }
} }

View file

@ -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) {
// Подтверждение обязательно: действие необратимо, аккаунт придётся // Подтверждение обязательно: действие необратимо, аккаунт придётся
// подключать заново. // подключать заново.

View file

@ -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):

View file

@ -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, "инструкция ведёт на несуществующий файл"