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:
parent
50e4de5230
commit
0be0a58a5d
3 changed files with 148 additions and 16 deletions
|
|
@ -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": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<div class="modal-feedback warning" style="margin-bottom:14px;">
|
<div id="device-auth-box" style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
|
||||||
<strong>Подключение через веб-интерфейс пока не реализовано.</strong><br>
|
<div style="color:var(--text-secondary);">Запрашиваем код у провайдера…</div>
|
||||||
Вход по коду устройства выполняется на стороне сервера, и этот поток
|
|
||||||
ещё не выведен в веб-API. Показывать здесь код было бы обманом:
|
|
||||||
настоящий код выдаёт провайдер, а не интерфейс.
|
|
||||||
<div style="margin-top:8px;">
|
|
||||||
<strong>Рабочий путь:</strong> подключите аккаунт в десктопном
|
|
||||||
приложении Hermes Hub — там поток проведён полностью и получает
|
|
||||||
настоящий адрес и код от провайдера.
|
|
||||||
</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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue