feat(antigravity): выход через прокси вместо патча бинарника

Вход через терминал прошёл: agy запустился в изолированном каталоге ag-5 и
опознал аккаунт владельца. Отказал Google: «Eligibility check failed: not
currently available in your location». Проверка смотрит на адрес выхода.

У владельца есть узлы 3x-ui в разных странах. Поэтому обход делается выходом
через разрешённую страну, а не патчем чужого бинарника: ничего не ломается при
обновлении Antigravity и не выполняется сторонний код.

Адрес задаётся общий в настройках и отдельный на профиль — разным аккаунтам
может требоваться разная страна. Применяется к запросу каталога моделей, к
вызовам моделей и к сценарию входа в терминале.

Пишутся и заглавные, и строчные имена переменных: Go читает HTTPS_PROXY,
многие библиотеки — https_proxy; ALL_PROXY нужен для socks5.

Адрес проверяется по существу, а не по схеме: приписать socks5:// можно чему
угодно, и тогда мусор выглядел бы принятым, а обращения провайдера молча
ломались бы без внятной причины.

695 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-09-01 16:49:04 +07:00
parent 28f35f863f
commit fa7bbef8af
9 changed files with 265 additions and 15 deletions

View file

@ -18,7 +18,7 @@ namespace HermesHubSetup
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик.
public const string BuildCommit = "884a632";
public const string BuildCommit = "28f35f8";
public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4";

Binary file not shown.

Binary file not shown.

View file

@ -205,15 +205,20 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
profile_dir = get_profile_env_dir(target_profile_id)
env = build_safe_subprocess_env(
overrides={
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
)
overrides = {
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
# Проверка доступности у Google смотрит на адрес: без выхода через
# разрешённую страну она отвечает «not currently available in your
# location», и каталог моделей получить нельзя.
overrides.update(proxy_env_overrides(resolve_provider_proxy(target_profile_id)))
env = build_safe_subprocess_env(overrides=overrides)
else:
env = build_safe_subprocess_env()
env = build_safe_subprocess_env(
overrides=proxy_env_overrides(resolve_provider_proxy())
)
try:
result = subprocess.run(
@ -718,6 +723,53 @@ BLOCKED_SECRET_PATTERNS: tuple[str, ...] = (
)
def resolve_provider_proxy(profile_id: str | None = None) -> str:
"""Адрес прокси для обращений провайдера: сначала профиль, затем общий.
Google отказывает по местоположению: «not currently available in your
location». Проверка смотрит на адрес, поэтому вопрос решается выходом
через нужную страну, а не правкой чужого бинарника.
Настройка на профиль важна не для красоты: у владельца несколько
выходных узлов в разных странах, и разным аккаунтам может требоваться
разный.
"""
if profile_id:
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
auth = ProfileAuthManager.load_profile_auth("antigravity", profile_id) or {}
own = str(auth.get("proxy_url") or "").strip()
if own:
return own
except Exception as exc:
logger.debug("Прокси профиля %s не прочитан: %s", profile_id, exc)
try:
from antigravity_provider.router.settings_service import get_hub_settings
return str(get_hub_settings().get("provider_proxy_url") or "").strip()
except Exception as exc:
logger.debug("Общая настройка прокси не прочитана: %s", exc)
return ""
def proxy_env_overrides(proxy_url: str) -> dict[str, str]:
"""Переменные окружения для прокси. Пустой адрес — пустой набор.
Пишем и заглавные, и строчные имена: Go читает HTTPS_PROXY, curl и
большинство библиотек https_proxy. ALL_PROXY нужен для socks5.
"""
url = (proxy_url or "").strip()
if not url:
return {}
names = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")
out: dict[str, str] = {}
for name in names:
out[name] = url
out[name.lower()] = url
return out
def detect_graphical_session() -> tuple[dict[str, str], list[str]]:
"""Найти графический сеанс владельца. Вернуть (переменные, что проверено).
@ -816,6 +868,19 @@ def write_login_helper(profile_dir: Path, agy_exe: str, profile_id: str) -> Path
"USERPROFILE=" + shlex.quote(str(profile_dir)),
"HOMEPATH=" + shlex.quote(str(profile_dir)),
"export HOME USERPROFILE HOMEPATH",
]
# Проверка доступности у Google смотрит на адрес выхода. Без прокси вход
# завершается «Eligibility check failed: not currently available in your
# location» — аккаунт при этом опознан верно, дело только в стране.
proxy = resolve_provider_proxy(profile_id)
if proxy:
for name, value in proxy_env_overrides(proxy).items():
lines.append(name + "=" + shlex.quote(value))
lines.append("export " + " ".join(sorted(proxy_env_overrides(proxy))))
lines.append('echo "Выход через прокси: ' + proxy.replace('"', "") + '"')
lines += [
"cd " + shlex.quote(str(profile_dir)) + " || exit 1",
'echo "Вход Antigravity в слот ' + profile_id + '"',
'echo "Каталог профиля: $HOME"',

View file

@ -54,14 +54,21 @@ class AntigravityAdapter(BaseProviderAdapter):
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
profile_dir = get_profile_env_dir(profile.profile_id)
# Isolate USERPROFILE and HOME while strictly stripping non-Antigravity provider secrets
custom_env = build_safe_subprocess_env(
overrides={
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
overrides = {
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
# Тот же прокси, что и при входе: иначе аккаунт подключён, а запросы
# упираются в проверку доступности по стране.
from antigravity_provider.agy_subprocess import (
proxy_env_overrides,
resolve_provider_proxy,
)
overrides.update(proxy_env_overrides(resolve_provider_proxy(profile.profile_id)))
custom_env = build_safe_subprocess_env(overrides=overrides)
# If profile specifies a preferred model and request has generic or no model
req = dict(request)
model = req.get("model", "")

View file

@ -6,6 +6,8 @@ along with Obsidian shared memory validation and canonical structure setup.
from __future__ import annotations
import json
from urllib.parse import urlparse
import re
import logging
import os
import tempfile
@ -36,6 +38,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"compression_threshold_percent": 75.0,
"compression_keep_recent_messages": 3,
"compression_enabled": True,
# Прокси для обращений провайдеров, у которых проверка доступности
# смотрит на адрес. Google отказал владельцу: «not currently available
# in your location». Задаётся адресом вида socks5://127.0.0.1:1080 или
# http://127.0.0.1:8080; пустое значение означает «без прокси».
"provider_proxy_url": "",
}
@ -57,6 +64,36 @@ def get_settings_file() -> Path:
return get_hermes_home() / "hub_settings.json"
def _normalize_proxy_url(raw: Any) -> str:
"""Привести адрес прокси к рабочему виду или отбросить.
Проверяем не только схему, но и сам адрес: приписать socks5:// можно
чему угодно, и тогда мусор выглядел бы принятым, а все обращения
провайдера молча ломались бы без внятной причины.
"""
value = str(raw or "").strip()
if not value:
return ""
if "://" not in value:
value = "socks5://" + value
try:
parsed = urlparse(value)
except ValueError:
return ""
if parsed.scheme not in ("http", "https", "socks5", "socks5h"):
return ""
host = parsed.hostname or ""
if not host or not re.fullmatch(r"[A-Za-z0-9._\-\[\]:]+", host):
return ""
try:
port = parsed.port
except ValueError:
return ""
if port is not None and not (1 <= port <= 65535):
return ""
return value
def get_hub_settings() -> Dict[str, Any]:
"""Load settings from hub_settings.json merged with standard defaults, cached by mtime."""
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
@ -139,6 +176,10 @@ def get_hub_settings() -> Dict[str, Any]:
except (ValueError, TypeError):
merged["compression_threshold_percent"] = 75.0
# Адрес прокси проверяем на вид, а не принимаем что попало: неверная
# схема тихо ломает все обращения провайдера, и причина не видна.
merged["provider_proxy_url"] = _normalize_proxy_url(merged.get("provider_proxy_url"))
try:
merged["compression_keep_recent_messages"] = max(1, min(20, int(merged.get("compression_keep_recent_messages", 3))))
except (ValueError, TypeError):

View file

@ -1727,6 +1727,18 @@ function renderSettingsView() {
}
}
// Прокси провайдеров: пустая строка — осмысленное значение «без прокси»,
// поэтому отличаем её от «сервер не передал».
const proxyInput = document.getElementById('setting-provider-proxy-url');
if (proxyInput) {
if (s.provider_proxy_url !== undefined) {
proxyInput.value = s.provider_proxy_url || '';
proxyInput.placeholder = 'без прокси';
} else {
proxyInput.placeholder = 'Н/Д: не передан сервером';
}
}
// Quota Interval
const quotaIntervalSel = document.getElementById('setting-quota-interval');
if (quotaIntervalSel) {
@ -1829,7 +1841,12 @@ async function saveHubServerSettings() {
const defaultRoleSel = document.getElementById('setting-default-role');
const themeSel = document.getElementById('setting-theme');
const proxyInputSave = document.getElementById('setting-provider-proxy-url');
const newSettings = {};
// Пустое поле — это выбор «без прокси», а не отсутствие значения:
// отправляем его тоже, иначе прокси нельзя было бы убрать.
if (proxyInputSave) newSettings.provider_proxy_url = proxyInputSave.value.trim();
if (hostInput && hostInput.value.trim()) newSettings.web_api_host = hostInput.value.trim();
if (portInput && portInput.value) newSettings.web_api_port = Number(portInput.value);
if (tokenInput && tokenInput.value.trim()) newSettings.web_api_token = tokenInput.value.trim();

View file

@ -455,6 +455,13 @@
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label" for="setting-provider-proxy-url">Прокси для провайдеров</label>
<div class="setting-desc">Google отказывает по местоположению: «not currently available in your location». Выход через разрешённую страну снимает отказ. Пример: <code>socks5://127.0.0.1:1080</code>. Пусто — без прокси. Отдельному аккаунту можно задать свой адрес.</div>
</div>
<div class="setting-control"><input id="setting-provider-proxy-url" class="input-text" type="text" placeholder="Н/Д: загрузка" aria-label="Адрес прокси для обращений провайдеров"></div>
</div>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label" for="setting-account-check-interval">Проверка аккаунтов и моделей</label>

View file

@ -0,0 +1,113 @@
"""Обход проверки доступности по стране — через прокси, а не патч бинарника.
Вход владельца через терминал прошёл: agy запустился в изолированном каталоге и
опознал аккаунт. Отказал Google: «Eligibility check failed: Your current account
is not eligible for Antigravity, because it is not currently available in your
location». Проверка смотрит на адрес выхода.
Правильный ответ выйти через разрешённую страну. У владельца несколько узлов
в разных странах, поэтому адрес задаётся и общий, и на каждый профиль отдельно.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from antigravity_provider.agy_subprocess import (
proxy_env_overrides,
resolve_provider_proxy,
write_login_helper,
)
from antigravity_provider.router.settings_service import _normalize_proxy_url
@pytest.mark.parametrize(
"given, expected",
[
("socks5://127.0.0.1:1080", "socks5://127.0.0.1:1080"),
("127.0.0.1:1080", "socks5://127.0.0.1:1080"),
("http://proxy.example.com:8080", "http://proxy.example.com:8080"),
("socks5h://10.0.0.5:1080", "socks5h://10.0.0.5:1080"),
("", ""),
(" ", ""),
],
)
def test_valid_addresses_are_kept(given, expected):
assert _normalize_proxy_url(given) == expected
@pytest.mark.parametrize(
"junk",
["мусор!!", "не адрес вовсе", "ftp://1.2.3.4:21", "socks5://1.2.3.4:99999", "://"],
)
def test_junk_is_rejected_not_dressed_up_with_a_scheme(junk):
"""Приписать socks5:// можно чему угодно.
Если бы мусор проходил, он выглядел бы принятым, а все обращения провайдера
молча ломались бы без внятной причины.
"""
assert _normalize_proxy_url(junk) == ""
def test_env_covers_upper_lower_and_socks():
env = proxy_env_overrides("socks5://127.0.0.1:1080")
# Go читает HTTPS_PROXY, curl и многие библиотеки — https_proxy,
# ALL_PROXY нужен для socks5.
assert set(env) == {
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "all_proxy",
}
assert set(env.values()) == {"socks5://127.0.0.1:1080"}
def test_no_proxy_means_no_variables():
assert proxy_env_overrides("") == {}
assert proxy_env_overrides(" ") == {}
def test_profile_address_wins_over_the_common_one():
"""Узлы в разных странах: разным аккаунтам может требоваться разный выход."""
with patch(
"antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth",
return_value={"proxy_url": "socks5://nl.example:1080"},
), patch(
"antigravity_provider.router.settings_service.get_hub_settings",
return_value={"provider_proxy_url": "socks5://de.example:1080"},
):
assert resolve_provider_proxy("ag-1") == "socks5://nl.example:1080"
def test_common_address_is_used_when_profile_has_none():
with patch(
"antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth",
return_value={},
), patch(
"antigravity_provider.router.settings_service.get_hub_settings",
return_value={"provider_proxy_url": "socks5://de.example:1080"},
):
assert resolve_provider_proxy("ag-1") == "socks5://de.example:1080"
def test_login_script_exports_the_proxy(tmp_path):
with patch(
"antigravity_provider.agy_subprocess.resolve_provider_proxy",
return_value="socks5://127.0.0.1:1080",
):
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-1").read_text(
encoding="utf-8"
)
assert "HTTPS_PROXY=" in body
assert "export " in body and "ALL_PROXY" in body
assert "socks5://127.0.0.1:1080" in body
def test_login_script_without_proxy_mentions_none(tmp_path):
with patch("antigravity_provider.agy_subprocess.resolve_provider_proxy", return_value=""):
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-1").read_text(
encoding="utf-8"
)
assert "PROXY" not in body