From de7c7132ae1d5c803585c52973ca792bc1b0c6d8 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 25 Aug 2026 10:55:41 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B8=20401=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=BB=D1=87=D0=B0=D0=BB=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D1=82=D0=BE=D0=B3=D0=BE,=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=20=D1=81=D0=BF=D1=80=D0=BE=D1=81=D0=B8=D1=82=D1=8C=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец открыл хаб по сети и получил пустую панель с повторяющимся тостом «Ошибка сервера: 401». Кода 401 клиент не знал вовсе: ответ падал в общую ветку ошибок, тост повторялся на каждом опросе, и ни одной подсказки о том, что нужен токен и где его взять, не было. Попытка ввести токен в «Настройках» тоже провалилась: рядом с полем токена стоит кнопка сохранения настроек СЕРВЕРА, а она сама шлёт save_settings и требует токен — получался замкнутый круг, 401 на попытке ввести токен от 401. Теперь 401 перехватывается и в fetchSnapshot, и в executeAction: опрос останавливается, открывается окно с полем ввода и объяснением, откуда взять значение. Токен проверяется настоящим запросом до сохранения, поэтому неверный не попадает в localStorage. После принятия окно закрывается, поле в настройках заполняется, опрос возобновляется. Дефект, найденный при проверке в браузере: непустой токен с не-ASCII символами роняет fetch на TypeError, и обработчик умирает молча, ничего не показав. Заголовки HTTP переносят только ASCII. Добавлена проверка до отправки и перехват сетевой ошибки — так бывает, когда вместе с токеном скопирован текст вокруг. Проверено в браузере на изолированном экземпляре: окно появляется само, кириллица даёт внятное сообщение, неверный токен отвергается и не сохраняется, верный принимается — панель оживает, индикатор Live API, опрос возобновлён. 432 passed, ruff чисто. Co-Authored-By: Claude Opus 5 --- .../router/web/static/app.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index ea5b56d..e0d02c6 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -184,6 +184,10 @@ async function fetchSnapshot() { if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/snapshot', { headers }); + if (res.status === 401) { + handleUnauthorized(); + return; + } if (!res.ok) { if (res.status === 404 || res.status === 502 || res.status === 503) { throw new Error(`Server returned ${res.status}`); @@ -286,6 +290,98 @@ function startPolling() { } } +// ── ЗАПРОС ТОКЕНА ПРИ 401 ── +// +// Сервер, привязанный не к localhost, требует X-Hub-Token. Раньше клиент этого +// случая не знал вовсе: 401 попадал в общую ветку ошибок и превращался в +// красный тост «Ошибка сервера: 401», который повторялся на каждом опросе. +// Владелец видел пустую панель и не имел ни одной подсказки, что нужен токен +// и где его взять. Теперь спрашиваем прямо, один раз. +let _tokenPromptOpen = false; + +function handleUnauthorized() { + if (_tokenPromptOpen) return; + _tokenPromptOpen = true; + // Опрос останавливаем, иначе окно ввода будет перекрываться новыми 401 + // каждые несколько секунд. Отдельной stopPolling в клиенте нет — таймер + // гасится напрямую, как это делает startPolling перед перезапуском. + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + setSourceIndicator(false, 'Требуется токен доступа'); + + elements.modalTitle.textContent = 'Требуется токен доступа'; + elements.modalBody.innerHTML = ` + + + +
+ Токен сохранится в этом браузере и больше спрашиваться не будет. +
+ `; + elements.modalFooter.innerHTML = ` + + `; + showModal(); + setTimeout(() => { + const el = document.getElementById('auth-token-input'); + if (el) el.focus(); + }, 50); +} + +async function saveAuthTokenFromPrompt() { + const input = document.getElementById('auth-token-input'); + const feedback = document.getElementById('auth-token-feedback'); + if (!input) return; + const value = (input.value || '').trim(); + if (!value) { + feedback.innerHTML = 'Поле пустое.'; + return; + } + + // Заголовки HTTP переносят только ASCII. Без этой проверки fetch бросает + // TypeError и обработчик умирает целиком, не показав ничего: так бывает, + // если вместе с токеном скопировали русский текст или лишний символ. + if (!/^[!-~]+$/.test(value)) { + feedback.innerHTML = 'В токене посторонние символы. Скопируйте только сам токен, без кавычек, пробелов и текста вокруг.'; + return; + } + + feedback.textContent = 'Проверяем…'; + let res; + try { + res = await fetch('/api/snapshot', { headers: { 'X-Hub-Token': value } }); + } catch (err) { + feedback.innerHTML = `Не удалось обратиться к серверу: ${escapeHtml(err.message)}`; + return; + } + if (res.status === 401) { + feedback.innerHTML = 'Токен не подошёл. Проверьте, что скопирован целиком.'; + return; + } + if (!res.ok) { + feedback.innerHTML = `Сервер ответил ${res.status}.`; + return; + } + + authToken = value; + localStorage.setItem('hermes_hub_token', authToken); + const tokenInput = document.getElementById('setting-client-token-input'); + if (tokenInput) tokenInput.value = authToken; + + _tokenPromptOpen = false; + closeModal(); + showToast('Токен принят', 'success'); + startPolling(); + fetchSnapshot(); +} + // ── ACTIONS EXECUTION (POST /api/action) ── async function executeAction(actionName, actionData = {}) { showToast(`Выполняется «${actionName}»...`, 'info'); @@ -299,6 +395,11 @@ async function executeAction(actionName, actionData = {}) { body: JSON.stringify({ action: actionName, data: actionData }), }); + if (res.status === 401) { + handleUnauthorized(); + return { ok: false, message: 'Требуется токен доступа' }; + } + const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` })); if (result.ok) {