feat(web): авторизация по коду устройства для Grok и Codex прямо из веба

Владелец упёрся в заглушку «подключение через веб-интерфейс пока не
реализовано» и не смог подключить Grok. Backend был готов давно —
start_grok_oauth и start_codex_oauth возвращают настоящие адрес и код, —
но наружу не выведен: подключить эти провайдеры можно было только из
десктопа.

Добавлены действия start_device_auth и poll_device_auth. Адрес и код
выдаёт ПРОВАЙДЕР, интерфейс их только отображает — никаких подставленных
значений, как было с выдуманными GRK-7842 и CDX-9104.

Клиент показывает ссылку с кнопками «Открыть» и «Копировать», код
крупно и моноширинным, и опрашивает состояние каждые три секунды. Отказ
и просроченный код показываются как окончательные, опрос прекращается —
это работает вместе с правкой 50e4de5, научившей опрос различать
authorization_pending, access_denied и expired_token.

Проверено через веб-API: start отдаёт настоящий адрес accounts.x.ai и
код, poll возвращает pending, несуществующая сессия — честный отказ.

Контракт поднят до 1.4, действий стало двадцать одно.

Это снимает главное препятствие к удалению десктопа: он был
единственным путём подключить Grok и Codex.

Тесты: 414 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-24 15:20:05 +07:00
parent 50e4de5230
commit 0be0a58a5d
3 changed files with 148 additions and 16 deletions

View file

@ -113,6 +113,13 @@ test
- **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов. - **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов.
- **`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": "<id>"}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом. - **`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": "<id>"}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом.
**`start_device_auth` / `poll_device_auth`** (добавлено при разборе жалобы «не даёт зайти в грок»): авторизация по коду устройства для Grok и OpenAI Codex прямо из веба. Раньше поток был только в десктопе, а веб-мастер показывал заглушку.
`start_device_auth``data: {"provider": "grok"|"openai-codex"}`. Возвращает `{"session_id", "url", "code", "profile_id"}`. **Адрес и код выдаёт провайдер**; интерфейс их только отображает и не имеет права подставлять свои.
`poll_device_auth``data: {"provider", "session_id"}`. `ok: true` со `status: "pending"` означает ожидание подтверждения; `status: "completed"` — аккаунт подключён; `ok: false` — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос.
### `GET /api/health` ### `GET /api/health`
`{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся. `{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.

View file

@ -238,6 +238,63 @@ class ActionExecutor:
""" """
pid = data.get('profile_id', '') pid = data.get('profile_id', '')
prov = data.get('provider', '') prov = data.get('provider', '')
# Device-flow для Grok и Codex через веб. Backend был готов давно, но
# наружу не выведен: веб-мастер показывал заглушку «не реализовано», и
# подключить эти провайдеры можно было только из десктопа. Настоящие
# адрес и код выдаёт провайдер — интерфейс их только отображает.
if action == 'start_device_auth':
provider = (data.get('provider') or '').strip().lower()
slot = data.get('profile_id') or AutoAssigner.find_free_slot(provider)
if not slot:
return {'ok': False, 'message': f'Нет свободного слота для провайдера {provider}'}
try:
if provider == 'grok':
from antigravity_provider.router.grok_oauth import start_grok_oauth
session_id, url, code = start_grok_oauth(slot)
elif provider in ('openai-codex', 'codex'):
from antigravity_provider.router.codex_oauth import start_codex_oauth
session_id, url, code = start_codex_oauth(slot)
else:
return {'ok': False, 'message': f'Провайдер {provider} не использует код устройства'}
except Exception as exc:
return {'ok': False, 'message': f'Не удалось начать авторизацию: {exc}'}
if not code:
return {'ok': False, 'message': 'Провайдер не выдал код устройства'}
return {
'ok': True,
'message': 'Код устройства получен',
'data': {'session_id': session_id, 'url': url, 'code': code, 'profile_id': slot},
}
if action == 'poll_device_auth':
provider = (data.get('provider') or '').strip().lower()
session_id = data.get('session_id') or ''
if provider == 'grok':
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
session = get_grok_oauth_session(session_id)
elif provider in ('openai-codex', 'codex'):
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
session = get_codex_oauth_session(session_id)
else:
return {'ok': False, 'message': f'Провайдер {provider} не использует код устройства'}
if session is None:
return {'ok': False, 'message': 'Сессия авторизации не найдена или истекла'}
status = getattr(session, 'status', 'unknown')
if status == 'completed':
return {'ok': True, 'message': 'Аккаунт подключён', 'data': {'status': status}}
if status in ('failed', 'timeout'):
reason = getattr(session, 'error_msg', None) or 'Авторизация не завершена'
return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
# Подключение аккаунта: для локального сервера это не навигация, а # Подключение аккаунта: для локального сервера это не навигация, а
# настоящее сохранение профиля с адресом. # настоящее сохранение профиля с адресом.
if action == 'add_account': if action == 'add_account':

View file

@ -1566,28 +1566,19 @@ function showWizardStep2(providerId) {
let bodyHtml = ''; let bodyHtml = '';
if (providerId === 'grok' || providerId === 'openai-codex') { if (providerId === 'grok' || providerId === 'openai-codex') {
// Здесь стояли ВЫДУМАННЫЕ код устройства (GRK-7842 / CDX-9104) и жёстко // Поток кода устройства проведён через веб-API. Адрес и код приходят от
// вписанный адрес x.ai/device, который отдаёт 404. Мастер не был подключён // ПРОВАЙДЕРА и подставляются сюда; ничего не вписано в код. Раньше здесь
// к серверу вовсе: пользователь вводил бы несуществующий код бесконечно. // стояли выдуманные GRK-7842 и CDX-9104 при жёстко вписанном адресе.
// Пока поток не проведён через API, честнее сказать правду и указать
// рабочий путь, чем показывать правдоподобную пустышку.
const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'; const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex';
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: Авторизация ${providerName} Шаг 2 из 3: Авторизация ${providerName} по коду устройства
</div>
<div class="modal-feedback warning" style="margin-bottom:14px;">
<strong>Подключение через веб-интерфейс пока не реализовано.</strong><br>
Вход по коду устройства выполняется на стороне сервера, и этот поток
ещё не выведен в веб-API. Показывать здесь код было бы обманом:
настоящий код выдаёт провайдер, а не интерфейс.
<div style="margin-top:8px;">
<strong>Рабочий путь:</strong> подключите аккаунт в десктопном
приложении Hermes Hub там поток проведён полностью и получает
настоящий адрес и код от провайдера.
</div> </div>
<div id="device-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<div style="color:var(--text-secondary);">Запрашиваем код у провайдера</div>
</div> </div>
`; `;
setTimeout(() => startDeviceAuth(providerId), 0);
} else if (providerId === 'antigravity') { } else if (providerId === 'antigravity') {
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);">
@ -1992,3 +1983,80 @@ function formatIsoDate(isoStr) {
return isoStr; return isoStr;
} }
} }
// ─────────────────────────────────────────────────────────────
// Авторизация по коду устройства (Grok, OpenAI Codex)
// ─────────────────────────────────────────────────────────────
let _deviceAuthTimer = null;
function stopDeviceAuthPolling() {
if (_deviceAuthTimer) {
clearInterval(_deviceAuthTimer);
_deviceAuthTimer = null;
}
}
async function startDeviceAuth(providerId) {
stopDeviceAuthPolling();
const box = document.getElementById('device-auth-box');
if (!box) return;
const res = await executeAction('start_device_auth', { provider: providerId });
if (!res || !res.ok) {
box.innerHTML = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
return;
}
const d = res.data || {};
window._wiz_device_session = d.session_id;
window._wiz_device_profile = d.profile_id;
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-auth-url" value="${escapeHtml(d.url || '')}" readonly>
<button class="btn btn-secondary btn-sm" onclick="window.open(document.getElementById('wiz-auth-url').value, '_blank')">Открыть</button>
<button class="btn btn-secondary btn-sm" onclick="navigator.clipboard.writeText(document.getElementById('wiz-auth-url').value); showToast('Ссылка скопирована', 'success');">Копировать</button>
</div>
<div style="font-weight:700; margin-bottom:6px;">2. Введите код:</div>
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
<div style="font-family:var(--font-mono); font-size:22px; font-weight:700; color:var(--text-accent); letter-spacing:2px;" id="wiz-auth-code">${escapeHtml(d.code || '')}</div>
<button class="btn btn-secondary btn-sm" onclick="navigator.clipboard.writeText(document.getElementById('wiz-auth-code').innerText); showToast('Код скопирован', 'success');">Копировать код</button>
</div>
<div id="device-auth-status" style="font-size:12px; color:var(--text-muted);">
3. Подтвердите доступ окно обновится само. Код живёт недолго, не откладывайте.
</div>
`;
_deviceAuthTimer = setInterval(() => pollDeviceAuth(providerId), 3000);
}
async function pollDeviceAuth(providerId) {
const status = document.getElementById('device-auth-status');
if (!status) {
stopDeviceAuthPolling();
return;
}
const res = await executeAction('poll_device_auth', {
provider: providerId,
session_id: window._wiz_device_session,
});
if (!res) return;
const state = (res.data || {}).status;
if (res.ok && state === 'completed') {
stopDeviceAuthPolling();
status.innerHTML = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
showToast('Аккаунт подключён', 'success');
fetchSnapshot();
return;
}
if (!res.ok) {
// Отказ и просроченный код — конечные исходы, а не ожидание.
stopDeviceAuthPolling();
status.innerHTML = `<span style="color:var(--status-error); font-weight:600;">${escapeHtml(res.message || 'Авторизация не завершена')}</span>`;
}
}