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` — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос.
**`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": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.

View file

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

View file

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

View file

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

View file

@ -1612,39 +1612,28 @@ function showWizardStep2(providerId) {
</div>
`;
setTimeout(() => startDeviceAuth(providerId), 0);
} else if (providerId === 'antigravity') {
} else if (providerId === 'antigravity' || providerId === 'claude') {
// Раньше здесь стояла заглушка: «авторизация через веб-интерфейс
// невозможна», со ссылкой на SSH и перенос каталога профилей. Это было
// неверно — сервер умеет принять вставленное вручную значение, поэтому
// браузер нужен ГДЕ УГОДНО, а не на машине с Hub.
const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude';
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Авторизация Google Antigravity
Шаг 2 из 3: Авторизация ${providerName}
</div>
<div class="modal-feedback info" style="margin-bottom:14px;">
<strong>Для серверов (Headless режим):</strong><br>
Провайдер Antigravity требует интерактивного входа через консоль <code>agy</code>. Авторизация напрямую через веб-интерфейс невозможна.
<div style="margin-top:6px;">
<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 style="margin-bottom:10px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">Слот, в который войти:</label>
<select class="input-text" style="width:100%;" id="wiz-redirect-slot">${buildSlotOptions(providerId)}</select>
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
</div>
</div>
`;
} else if (providerId === 'claude') {
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Авторизация Claude
<div style="margin-bottom:10px;">
<button class="btn btn-primary btn-sm" onclick="startRedirectAuth('${escapeHtml(providerId)}')">Получить ссылку</button>
</div>
<div class="modal-feedback info" style="margin-bottom:14px;">
<strong>Для серверов (Headless режим):</strong><br>
Провайдер 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 id="redirect-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>
`;
} else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') {
@ -1974,6 +1963,8 @@ function showModal() {
function closeModal() {
_openAccountModalProfile = null;
stopDeviceAuthPolling();
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
stopRedirectAuthPolling();
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) {
// Подтверждение обязательно: действие необратимо, аккаунт придётся
// подключать заново.

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"
ok, msg = session.handle_manual_callback_url(pasted_url)
# Главное — свойство безопасности: чужой state отвергнут и обмена
# кода на токены не произошло.
assert ok is False
assert "Несовпадение" in msg or "state" in msg
assert session.status == "failed"
assert "сессии" in msg or "state" in msg
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
def test_d_oauth_error_callback(tmp_path, monkeypatch):

View file

@ -110,9 +110,30 @@ def test_headless_server_auth_matrix():
"настоящий приходит от провайдера в verification_uri"
)
# Честность про сервер без экрана должна остаться.
assert "Headless" in app_js, "нет предупреждения про сервер без экрана"
assert "agy" in app_js, "не сказано, что вход идёт через консоль agy"
# Раньше здесь требовалось слово "Headless": мастер писал, что на сервере
# без экрана вход невозможен, и отправлял в консоль по SSH. Утверждение
# оказалось ложным — сервер принимает вставленное вручную значение
# (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, "инструкция ведёт на несуществующий файл"