hermes-hub/src/antigravity_provider/agy_subprocess.py
Hermes Team 23e9ac1d6b review(a57): нативный вход через agy принят с исправлениями
Проверено исполнением, а не по отчёту.

Работает. Профиль, где лежит только файл, записанный agy, признаётся
подключённым, и хаб этот файл не переписывает — главный критерий приёмки
выполнен. Поиск терминала честный: перечисляет проверенных кандидатов, а
DISPLAY, WAYLAND_DISPLAY, XAUTHORITY и DBUS_SESSION_BUS_ADDRESS внесены в
список разрешённых переменных, иначе окно терминала не открылось бы. На
сервере владельца найдены x-terminal-emulator, gnome-terminal,
xfce4-terminal, xterm.

Исправлено два дефекта.

1. Почта после входа искалась в google_accounts.json, auth.json и id_token.
   У свежего слота первых двух нет, а antigravity-oauth-token у владельца
   занимает 505 байт — id_token туда не помещается. Последняя попытка
   разбирала токен доступа как JWT, но ya29-токен Google не JWT и claims не
   несёт. Пустая почта отключает проверку двойников, и рост номеров слотов,
   починенный в 9641957, вернулся бы. Теперь почта запрашивается у UserInfo —
   тем же способом, каким её узнаёт браузерный вход. Отказ сети вход не
   роняет: аккаунт подключён, почта Н/Д.

2. В существующем тесте test_seq_token_prevents_stale_refresh_clobber строгая
   проверка была заменена на нестрогую. Прогнал исходную пять раз подряд и в
   полном наборе — проходит. Ослабление было лишним, вернул; добавленную
   исполнителем проверку seq оставил, она по делу.

Не выполнено исполнителем: живая проверка входа на сервере (P0-7.1) — вместо
неё двенадцать модульных тестов. Вход требует участия владельца, поэтому
проверить его сам не могу.

Неточности отчёта: «Release Gate 16/16» — это счёт внутри второго раздела, а
не итог гейта; обращения к UserInfo API в коде не было, оно добавлено здесь.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 14:54:59 +07:00

1285 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AGY subprocess backend for the Antigravity Hermes provider plugin.
Routes chat-completion requests through the locally-installed ``agy`` CLI
instead of making direct HTTP calls to the Cloud Code Assist API. This
avoids the 429 RESOURCE_EXHAUSTED errors seen with direct API access while
reusing agy's existing authentication session.
Usage (from hermes_plugin.py middleware)::
from .agy_subprocess import agy_generate
completion_dict = agy_generate(openai_request_dict)
"""
from __future__ import annotations
import json
import logging
import os
import re
import secrets
import shutil
import subprocess
import time
import uuid
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# AGY executable discovery
# ---------------------------------------------------------------------------
_agy_exe_cache: str | None = None
def _find_agy_exe() -> str:
"""Locate the ``agy`` (or ``agy.exe``) binary.
Resolution order:
1. ``AGY_EXE_PATH`` environment variable (explicit override)
2. ``%LOCALAPPDATA%/agy/bin/agy.exe`` (standard Windows install)
3. ``PATH`` lookup via :func:`shutil.which`
"""
# 1. Explicit env var
env = os.environ.get("AGY_EXE_PATH", "").strip()
if env and Path(env).is_file():
return env
# 2. Стандартные места установки.
#
# Раньше проверялась только раскладка Windows (%LOCALAPPDATA%/agy/bin), а в
# Linux agy ставится в ~/.local/bin. Хаб запускается с урезанным окружением,
# где этого каталога в PATH нет, и вход в Antigravity падал с «agy executable
# not found» при установленной и работающей утилите.
from antigravity_provider.paths import get_hermes_home
exe_name = "agy.exe" if os.name == "nt" else "agy"
candidates = [
get_hermes_home().parent / "agy" / "bin" / exe_name,
Path.home() / ".local" / "bin" / exe_name,
Path("/usr/local/bin") / exe_name,
Path("/usr/bin") / exe_name,
Path("/snap/bin") / exe_name,
]
checked = []
for candidate in candidates:
checked.append(str(candidate))
try:
if candidate.is_file():
return str(candidate)
except OSError as exc:
# Каталог может быть закрыт правами: это «не смогли проверить»,
# а не «файла нет».
checked[-1] = f"{candidate} (нет доступа: {exc.strerror or exc})"
# 3. PATH
found = shutil.which("agy") or shutil.which("agy.exe")
if found:
return found
raise FileNotFoundError(
"Утилита agy не найдена. Проверено: "
+ "; ".join(checked)
+ "; и PATH процесса. Задайте путь переменной AGY_EXE_PATH либо установите agy."
)
def get_agy_exe() -> str:
"""Return the cached path to the ``agy`` binary."""
global _agy_exe_cache
if _agy_exe_cache is None:
_agy_exe_cache = _find_agy_exe()
return _agy_exe_cache
# ---------------------------------------------------------------------------
# Model discovery (dynamic — no hard-coded catalog)
# ---------------------------------------------------------------------------
# Maps *display* base names (lowered, dashed) → confirmed agy CLI model ids.
# Populated lazily by :func:`discover_models`.
_AGY_MODEL_CACHE: dict[str, str] | None = None
# Maps agy CLI model id → set of supported effort levels.
# Populated alongside _AGY_MODEL_CACHE by :func:`discover_models`.
_AGY_EFFORT_MAP: dict[str, set[str]] = {}
def _display_to_cli(display_name: str) -> tuple[str, str]:
"""Convert ``'Gemini 3.7 Flash (High)'`` → ``('gemini-3.7-flash', 'high')``.
Returns ``(cli_model, effort)``.
"""
m = re.match(r"^(.+?)\s*\((\w+)\)\s*$", display_name.strip())
if not m:
return display_name.strip().lower().replace(" ", "-"), ""
raw_name = m.group(1).strip()
effort = m.group(2).strip().lower()
# Normalise: lower-case, replace spaces with dashes
cli = raw_name.lower().replace(" ", "-")
# "4.6" → "4-6" only for non-gemini (gemini keeps dots: 3.7, 3.6 …)
if not cli.startswith("gemini"):
cli = cli.replace(".", "-")
return cli, effort
def discover_models(profile_id: str | None = None) -> dict[str, str]:
"""Discover available models by querying ``agy models``.
Returns a dict mapping *hermes-style* model ids
(``google-antigravity/gemini-3.7-flash``) to *agy CLI* model ids
(``gemini-3.7-flash``). The result is cached for the process lifetime.
Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model.
"""
global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP
if profile_id is None and _AGY_MODEL_CACHE:
return dict(_AGY_MODEL_CACHE)
exe = get_agy_exe()
# agy читает учётные данные из $HOME/$USERPROFILE, а адаптер подменяет их
# на каталог профиля — так шесть аккаунтов и сосуществуют. Раньше эта
# команда запускалась в ГЛОБАЛЬНОМ окружении, где вход не выполнен, и
# отвечала «Please sign in to view available models» — при шести рабочих
# OAuth-профилях. Список моделей поэтому был пуст всегда.
target_profile_id = profile_id
if target_profile_id:
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
st = ProfileAuthManager.get_profile_status("antigravity", target_profile_id)
if not st.get("authenticated") and not ProfileAuthManager.load_profile_auth("antigravity", target_profile_id):
target_profile_id = None
except Exception:
pass
if not target_profile_id:
try:
from antigravity_provider.paths import get_hermes_home
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import load_router_config
candidate_pids: list[str] = []
try:
cfg = load_router_config()
for pid, pcfg in cfg.profiles.items():
if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids:
candidate_pids.append(pid)
except Exception:
pass
main_p = ProfileAuthManager.get_main_profile("antigravity")
if main_p and main_p not in candidate_pids:
candidate_pids.append(main_p)
standard_slots = (
["ag-orch-primary", "ag-orch-fallback"]
+ [f"ag-{i}" for i in range(1, 21)]
+ [f"ag-w{i}" for i in range(1, 11)]
)
for s in standard_slots:
if s not in candidate_pids:
candidate_pids.append(s)
try:
agy_dir = get_hermes_home() / "agy_profiles"
if agy_dir.is_dir():
for sub in sorted(agy_dir.iterdir()):
if sub.is_dir() and sub.name not in candidate_pids:
candidate_pids.append(sub.name)
except Exception:
pass
for candidate in candidate_pids:
st = ProfileAuthManager.get_profile_status("antigravity", candidate)
if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", candidate):
target_profile_id = candidate
break
except Exception:
target_profile_id = None
if target_profile_id:
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),
}
)
else:
env = build_safe_subprocess_env()
try:
result = subprocess.run(
[exe, "models"],
capture_output=True,
text=True,
timeout=60,
encoding="utf-8",
errors="replace",
env=env,
stdin=subprocess.DEVNULL,
**hidden_process_kwargs(),
)
raw = result.stdout.strip()
if not raw or result.returncode != 0:
if profile_id:
# Прежнее сообщение «код 1; каталог не получен» скрывало причину.
# agy пишет её в stderr, и без неё непонятно главное: он
# запускается с HOME, подменённым на каталог профиля (ради
# изоляции учётных данных между аккаунтами). Если вход
# выполнялся обычным agy в оболочке, ключи легли в настоящий
# домашний каталог, и профиль пуст — отсюда отказ.
detail = (result.stderr or "").strip() or (result.stdout or "").strip()
detail = detail.splitlines()[-1][:300] if detail else "вывод пуст"
raise RuntimeError(
f"agy models: код {result.returncode}. Ответ agy: {detail}. "
f"Запуск с HOME={env.get('HOME') or env.get('USERPROFILE') or 'не задан'}"
)
return dict(_AGY_MODEL_CACHE or {})
except Exception:
if profile_id:
raise
return dict(_AGY_MODEL_CACHE or {})
models: dict[str, str] = {}
effort_map: dict[str, set[str]] = {}
for line in raw.splitlines():
line = line.strip()
if not line or line.lower().startswith("model"):
continue
parts = line.split('\t')
cli_model = parts[0].strip()
if not cli_model:
continue
hermes_id = f"google-antigravity/{cli_model}"
models[hermes_id] = cli_model
if cli_model not in effort_map:
effort_map[cli_model] = set()
# Parse effort from description or known capabilities if needed
# Assuming effort is not explicitly provided in the tabbed output or we extract it
if len(parts) > 1:
desc = parts[1].strip().lower()
if "(high)" in desc:
effort_map[cli_model].add("high")
if "(low)" in desc:
effort_map[cli_model].add("low")
if "(medium)" in desc:
effort_map[cli_model].add("medium")
_AGY_MODEL_CACHE = models
_AGY_EFFORT_MAP = effort_map
logger.info(
"discover_models: found %d models, efforts=%s",
len(models),
{k: sorted(v) for k, v in effort_map.items()},
)
return dict(models)
def check_profile_native_auth_status(profile_id: str) -> tuple[bool, str | None, dict[str, Any] | None]:
"""Check if agy native authentication has completed in profile's directory.
Returns (is_authenticated, email, auth_data).
A22/A57 Requirement: Detection without credential logging or stream interception.
Checks .gemini/antigravity-cli/antigravity-oauth-token first, then .gemini/oauth_creds.json.
"""
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
profile_dir = get_profile_env_dir(profile_id)
gemini_dir = profile_dir / ".gemini"
cli_dir = gemini_dir / "antigravity-cli"
cli_token_file = cli_dir / "antigravity-oauth-token"
creds_file = gemini_dir / "oauth_creds.json"
accounts_file = gemini_dir / "google_accounts.json"
auth_file = profile_dir / "auth.json"
token_data: dict[str, Any] | None = None
auth_method: str = "consumer"
# 1. Primary source: agy 2.0 native token file (.gemini/antigravity-cli/antigravity-oauth-token)
if cli_token_file.is_file() and cli_token_file.stat().st_size > 0:
try:
cli_raw = json.loads(cli_token_file.read_text(encoding="utf-8"))
if isinstance(cli_raw, dict):
auth_method = cli_raw.get("auth_method", "consumer")
inner = cli_raw.get("token")
if isinstance(inner, dict):
token_data = inner
elif "access_token" in cli_raw or "refresh_token" in cli_raw:
token_data = cli_raw
except Exception as exc:
logger.debug("Error reading cli_token_file for %s: %s", profile_id, exc)
# 2. Secondary source: Gemini CLI legacy credentials (.gemini/oauth_creds.json)
if not token_data and creds_file.is_file() and creds_file.stat().st_size > 0:
try:
creds_raw = json.loads(creds_file.read_text(encoding="utf-8"))
if isinstance(creds_raw, dict):
token_data = creds_raw
auth_method = "oauth"
except Exception as exc:
logger.debug("Error reading creds_file for %s: %s", profile_id, exc)
# 3. Third source: existing auth.json
if not token_data and auth_file.is_file() and auth_file.stat().st_size > 0:
try:
a_raw = json.loads(auth_file.read_text(encoding="utf-8"))
if isinstance(a_raw, dict):
inner = a_raw.get("token") or a_raw.get("tokens")
if isinstance(inner, dict):
token_data = inner
auth_method = a_raw.get("auth_method", "oauth")
except Exception as exc:
logger.debug("Error reading auth_file for %s: %s", profile_id, exc)
if not token_data or not isinstance(token_data, dict):
return False, None, None
access_token = token_data.get("access_token")
refresh_token = token_data.get("refresh_token")
if not access_token and not refresh_token:
return False, None, None
# Extract email identity truthfully (P0-3: do not invent)
email: str | None = None
if accounts_file.is_file() and accounts_file.stat().st_size > 0:
try:
acc_data = json.loads(accounts_file.read_text(encoding="utf-8"))
if isinstance(acc_data, dict) and acc_data.get("active"):
em = str(acc_data["active"]).strip()
if "@" in em:
email = em
except Exception:
email = None
if not email and auth_file.is_file():
try:
a_data = json.loads(auth_file.read_text(encoding="utf-8"))
if isinstance(a_data, dict):
em = a_data.get("email") or a_data.get("user_email")
if em and "@" in str(em):
email = str(em).strip()
except Exception:
pass
if not email:
from antigravity_provider.router.profile_manager import ProfileAuthManager
id_token = token_data.get("id_token")
if id_token:
jwt_email, _ = ProfileAuthManager.extract_jwt_identity(str(id_token))
if jwt_email and "@" in jwt_email:
email = jwt_email
# Токен доступа Google (ya29....) — не JWT, разбирать его как JWT
# бессмысленно. Почту по нему отдаёт UserInfo, и этим же путём её узнаёт
# браузерный вход. Без почты не срабатывает проверка двойников, и один
# аккаунт снова расползётся по слотам — ровно та беда, которую чинили.
#
# Спрашиваем провайдера, только если в самом профиле почты не нашлось:
# у свежего слота нет ни google_accounts.json, ни auth.json, а файл
# antigravity-oauth-token (505 байт у владельца) id_token не содержит.
if not email and access_token:
try:
from antigravity_provider.oauth import fetch_user_email
remote_email = fetch_user_email(str(access_token))
if remote_email and "@" in remote_email:
email = remote_email.strip()
except Exception as exc:
# Отказ сети не должен ронять вход: аккаунт подключён, почта — Н/Д.
logger.info("Почту через UserInfo установить не удалось: %s", exc)
auth_data = {
"auth_method": auth_method,
"email": email or "",
"token": token_data,
"updated_at": time.time(),
}
# Keep profile's auth.json in sync
auth_file = profile_dir / "auth.json"
if not auth_file.is_file():
try:
auth_file.write_text(json.dumps(auth_data, indent=2, ensure_ascii=False), encoding="utf-8")
os.chmod(auth_file, 0o600)
except Exception:
pass
# Ensure profile permissions (0700 on dirs, 0600 on files)
try:
os.chmod(profile_dir, 0o700)
if gemini_dir.is_dir():
os.chmod(gemini_dir, 0o700)
if cli_dir.is_dir():
os.chmod(cli_dir, 0o700)
if cli_token_file.is_file():
os.chmod(cli_token_file, 0o600)
if creds_file.is_file():
os.chmod(creds_file, 0o600)
if auth_file.is_file():
os.chmod(auth_file, 0o600)
except OSError:
pass
return True, email, auth_data
def _model_supported_efforts(agy_model: str, profile_id: str | None = None) -> set[str]:
"""Return the set of effort levels supported by *agy_model*.
Профиль обязателен: обнаружение читает учётные данные из HOME, и без
подмены окружения оно выполняется в глобальном, где вход не сделан.
Карта усилий тогда остаётся пустой, подстановка уровня по умолчанию не
срабатывает, и agy отвергает вызов с «requires --effort» — при том что
сама модель совершенно настоящая.
"""
discover_models(profile_id=profile_id)
efforts = _AGY_EFFORT_MAP.get(agy_model, set())
if efforts:
return efforts
# Запасной источник: сохранённый на диске список моделей. Обнаружение
# ходит через КОНКРЕТНЫЙ профиль, и если именно у него вход не сделан,
# карта усилий остаётся пустой — тогда уровень не подставляется и agy
# отвергает вызов, хотя модель настоящая. Уровни усилия — свойство
# модели, а не аккаунта, поэтому их можно взять из склеенных
# идентификаторов вида "gemini-3.7-flash-high", уже лежащих в кэше.
try:
from antigravity_provider.router.model_discovery import ModelDiscoveryService
cached = ModelDiscoveryService.get().get_models("antigravity") or []
except Exception:
return efforts
known = {"low", "medium", "high"}
derived = {
suffix
for model_id in cached
for base, _, suffix in [model_id.rpartition("-")]
if base == agy_model and suffix in known
}
return derived
# Effort values understood by both hermes and agy
_EFFORT_NORMALISE: dict[str, str] = {
"off": "",
"none": "",
"disabled": "",
"minimal": "low",
"minimum": "low",
"low": "low",
"medium": "medium",
"normal": "medium",
"high": "high",
"xhigh": "high",
"max": "high",
}
def _resolve_effort(raw: str | None) -> str:
"""Normalise a hermes reasoning_effort value to an agy ``--effort`` arg."""
if not raw:
return ""
return _EFFORT_NORMALISE.get(raw.strip().lower().replace("_", "-"), "")
def _resolve_model(hermes_model: str) -> str:
"""Convert a hermes model id to an agy ``--model`` arg.
Falls back to stripping the ``google-antigravity/`` prefix.
"""
catalog = discover_models()
if hermes_model in catalog:
return catalog[hermes_model]
# Strip provider prefix
if "/" in hermes_model:
return hermes_model.split("/", 1)[1]
return hermes_model
# ---------------------------------------------------------------------------
# Conversation serialisation
# ---------------------------------------------------------------------------
def _content_text(content: Any) -> str:
"""Extract plain text from an OpenAI ``content`` field."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(str(item.get("text", "")))
elif item.get("type") == "image_url":
parts.append("[image]")
return "\n".join(parts)
return str(content)
def _serialize_tool_defs(tools: list[dict[str, Any]]) -> str:
"""Serialise OpenAI tool definitions into a readable block."""
lines: list[str] = []
for tool in tools:
if not isinstance(tool, dict) or tool.get("type") != "function":
continue
fn = tool.get("function") or {}
if not isinstance(fn, dict) or not fn.get("name"):
continue
lines.append(json.dumps(fn, indent=2, ensure_ascii=False))
if not lines:
return ""
return (
"## SYSTEM DIRECTIVE: FUNCTION CALLING MODE\n"
"You are acting as an API LLM backend for Hermes Agent. You do NOT possess any built-in tools, shell access, or local file system access.\n"
"To perform any operation, inspect files, or call functions, you MUST output a tool call block using the exact format below. Do NOT attempt to run commands or read files yourself.\n\n"
"```tool_call\n"
'{"name": "<tool_name>", "arguments": {<args>}}\n'
"```\n\n"
"### Available Client Functions:\n"
+ "\n---\n".join(lines)
)
def serialize_messages(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> str:
"""Flatten an OpenAI messages array (+ tools) into a single text prompt.
For simple system-plus-one-user prompts (no tools, no history) the output
is a clean concatenation — no role tags — so the model behaves as if it
received a direct instruction.
For multi-turn or tool-bearing conversations each turn is tagged with
``[User]``, ``[Assistant]``, ``[Tool …]`` to preserve structure.
"""
system_parts: list[str] = []
turns: list[tuple[str, str]] = [] # (role_tag, text)
for msg in messages:
role = msg.get("role", "")
content = _content_text(msg.get("content"))
if role in ("system", "developer"):
if content.strip():
system_parts.append(content)
elif role == "user":
if content.strip():
turns.append(("user", content))
elif role == "assistant":
parts: list[str] = []
if content and content.strip():
parts.append(content)
for tc in msg.get("tool_calls") or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function") or {}
if isinstance(fn, dict) and fn.get("name"):
parts.append(
f'[Tool call: {fn["name"]}'
f'({fn.get("arguments", "{}")})]'
)
if parts:
turns.append(("assistant", "\n".join(parts)))
elif role == "tool":
name = msg.get("name", "tool")
if content.strip():
turns.append(("tool", f"[Tool result ({name})]: {content}"))
# --- simple case: system + single user, no tools ---
if (
len(turns) == 1
and turns[0][0] == "user"
and not tools
):
prefix = "\n\n".join(system_parts)
body = turns[0][1]
return f"{prefix}\n\n{body}" if prefix else body
# --- complex case: multi-turn / tools ---
sections: list[str] = []
if system_parts:
sections.append("\n\n".join(system_parts))
if tools:
td = _serialize_tool_defs(tools)
if td:
sections.append(td)
for tag, text in turns:
if tag == "user":
sections.append(f"[User]\n{text}")
elif tag == "assistant":
sections.append(f"[Assistant]\n{text}")
elif tag == "tool":
sections.append(text)
return "\n\n".join(sections)
# ---------------------------------------------------------------------------
# Tool-call extraction from model text
# ---------------------------------------------------------------------------
def _extract_tool_calls(text: str) -> list[dict[str, Any]] | None:
"""Best-effort extraction of ``tool_call`` blocks from model output."""
pattern = r"```tool_call\s*\n(.*?)\n```"
matches = re.findall(pattern, text, re.DOTALL)
if not matches:
return None
calls: list[dict[str, Any]] = []
for raw in matches:
try:
data = json.loads(raw.strip())
name = data.get("name", "")
args = data.get("arguments", {})
if not name:
continue
if not isinstance(args, dict):
try:
args = json.loads(str(args))
except (json.JSONDecodeError, TypeError):
args = {}
calls.append(
{
"id": "call_" + uuid.uuid4().hex[:12],
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(
args, separators=(",", ":"), ensure_ascii=False
),
},
}
)
except (json.JSONDecodeError, AttributeError):
continue
return calls if calls else None
def _strip_tool_call_blocks(text: str) -> str:
"""Remove ``tool_call`` fenced blocks from the response text."""
cleaned = re.sub(r"```tool_call\s*\n.*?\n```", "", text, flags=re.DOTALL)
return cleaned.strip()
# ---------------------------------------------------------------------------
# Safe environment (no hermes secrets in subprocess)
# ---------------------------------------------------------------------------
SAFE_SYSTEM_ENV_VARS: set[str] = {
# Windows standard environment
"SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATH", "PATHEXT",
"TEMP", "TMP", "LOCALAPPDATA", "APPDATA", "PROGRAMDATA", "PROGRAMFILES",
"PROGRAMFILES(X86)", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)",
"USERPROFILE", "HOME", "HOMEDRIVE", "HOMEPATH",
"NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER",
"OS", "COMPUTERNAME", "LOGONSERVER", "USERDOMAIN", "USERNAME",
# Unix standard environment
"USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES",
"TMPDIR", "TERM", "PWD", "COLORTERM",
# GUI Display and session environment (for terminal emulators)
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP",
# Networking & Proxy & SSL certificates
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "no_proxy", "all_proxy",
"SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS",
# Specific toolchain overrides (non-secret)
"AGY_EXE_PATH", "AGY_CONFIG_PATH", "HERMES_HOME", "HERMES_HUB_DEV_MODE",
"NODE_OPTIONS", "PYTHONUTF8", "PYTHONIOENCODING",
}
BLOCKED_SECRET_PATTERNS: tuple[str, ...] = (
"api_key", "token", "secret", "auth", "password", "bearer", "private_key",
"openai", "codex", "anthropic", "claude", "deepseek", "opencode", "xai", "grok",
"hermes_api", "hermes_secret", "google_api_key", "gemini_api",
)
def find_terminal_emulator(
profile_id: str,
agy_exe: str,
profile_dir: Path,
) -> tuple[list[str] | None, str | None, list[str]]:
"""Locate an available GUI terminal emulator on the host system to run native agy CLI login.
Returns:
(command_args, error_message, checked_candidates)
"""
title = f"Antigravity Login ({profile_id})"
if os.name == "nt":
checked = ["Windows Terminal (wt.exe)", "cmd.exe", "powershell.exe"]
wt_path = shutil.which("wt.exe") or shutil.which("wt")
if wt_path:
cmd = [wt_path, "-w", "0", "nt", "-d", str(profile_dir), "--title", title, agy_exe]
return cmd, None, checked
cmd_path = shutil.which("cmd.exe") or shutil.which("cmd") or "cmd.exe"
cmd = [cmd_path, "/c", "start", title, agy_exe]
return cmd, None, checked
# Linux / Unix / macOS
display = os.environ.get("DISPLAY", "").strip()
wayland = os.environ.get("WAYLAND_DISPLAY", "").strip()
mir = os.environ.get("MIR_SOCKET", "").strip()
if not display and not wayland and not mir:
checked = [
f"DISPLAY ({'задан: ' + os.environ['DISPLAY'] if 'DISPLAY' in os.environ else 'не задан'})",
f"WAYLAND_DISPLAY ({'задан: ' + os.environ['WAYLAND_DISPLAY'] if 'WAYLAND_DISPLAY' in os.environ else 'не задан'})",
f"MIR_SOCKET ({'задан: ' + os.environ['MIR_SOCKET'] if 'MIR_SOCKET' in os.environ else 'не задан'})",
]
err_msg = (
"Графический дисплей не обнаружен (переменные DISPLAY/WAYLAND_DISPLAY не заданы). "
"Для входа на сервере без графического интерфейса используйте вход по ссылке через браузер "
"либо запустите Hub в сессии с графическим дисплеем."
)
return None, err_msg, checked
candidates: list[tuple[str, Any]] = [
("x-terminal-emulator", lambda p: [p, "-e", agy_exe]),
("gnome-terminal", lambda p: [p, "--title", title, "--", agy_exe]),
("konsole", lambda p: [p, "-p", f"tabtitle={title}", "-e", agy_exe]),
("xfce4-terminal", lambda p: [p, "--title", title, "-e", agy_exe]),
("tilix", lambda p: [p, "-t", title, "-e", agy_exe]),
("alacritty", lambda p: [p, "-t", title, "-e", agy_exe]),
("kitty", lambda p: [p, "--title", title, agy_exe]),
("terminator", lambda p: [p, "-T", title, "-e", agy_exe]),
("urxvt", lambda p: [p, "-title", title, "-e", agy_exe]),
("foot", lambda p: [p, "--title", title, agy_exe]),
("xterm", lambda p: [p, "-title", title, "-e", agy_exe]),
]
checked = []
for name, cmd_builder in candidates:
found_path = shutil.which(name)
if found_path:
checked.append(f"{name} (найден: {found_path})")
return cmd_builder(found_path), None, checked
else:
checked.append(f"{name} (не найден)")
err_msg = (
"Терминал не найден на сервере. Проверено: "
+ "; ".join(checked)
+ "; и PATH процесса. Запустите вход через браузер либо установите терминал "
"(например, gnome-terminal, xfce4-terminal или xterm)."
)
return None, err_msg, checked
class NativeAgySession:
"""Tracks a native agy CLI terminal login session."""
def __init__(self, profile_id: str, profile_dir: Path, timeout_sec: int = 600):
self.session_id = secrets.token_urlsafe(16)
self.profile_id = profile_id
self.profile_dir = profile_dir
self.timeout_sec = timeout_sec
self.created_at = time.time()
self.status = "pending" # pending, completed, timeout, failed, cancelled
self.error_msg: str | None = None
self.terminal_cmd: list[str] | None = None
self.token_path = profile_dir / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
self.creds_path = profile_dir / ".gemini" / "oauth_creds.json"
self.initial_token_mtime = self._get_token_mtime()
def _get_token_mtime(self) -> float:
mtime = 0.0
if self.token_path.is_file():
try:
mtime = max(mtime, self.token_path.stat().st_mtime)
except OSError:
pass
if self.creds_path.is_file():
try:
mtime = max(mtime, self.creds_path.stat().st_mtime)
except OSError:
pass
return mtime
def check_status(self) -> tuple[bool, str, dict[str, Any]]:
"""Poll the filesystem to verify if agy created the authentication credentials."""
if self.status == "completed":
return True, "Авторизация успешно завершена", {
"status": "completed",
"profile_id": self.profile_id,
}
if self.status in ("failed", "cancelled"):
return False, self.error_msg or "Авторизация отменена", {
"status": self.status,
"profile_id": self.profile_id,
}
now = time.time()
if now - self.created_at > self.timeout_sec:
self.status = "timeout"
self.error_msg = (
f"Время ожидания авторизации в терминале истекло ({int(self.timeout_sec // 60)} минут). "
f"Файл учётных данных не появился в {self.profile_dir}."
)
return False, self.error_msg, {
"status": "timeout",
"profile_id": self.profile_id,
"home": str(self.profile_dir),
}
is_authenticated, email, auth_data = check_profile_native_auth_status(self.profile_id)
current_mtime = self._get_token_mtime()
if is_authenticated and (current_mtime >= (self.created_at - 2.0) or self.initial_token_mtime == 0.0):
# Check duplicate identity
if email:
try:
from antigravity_provider.router.auto_assigner import AutoAssigner
existing = AutoAssigner.check_duplicate_identity(
"antigravity", email, exclude_profile_id=self.profile_id
)
if existing and existing != self.profile_id:
logger.info(
"Native agy login: identity %s already exists in %s, syncing from %s",
email, existing, self.profile_id,
)
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
target_dir = get_profile_env_dir(existing)
target_gemini = target_dir / ".gemini"
target_gemini.mkdir(parents=True, exist_ok=True)
src_gemini = self.profile_dir / ".gemini"
if src_gemini.is_dir():
shutil.copytree(src_gemini, target_gemini, dirs_exist_ok=True)
self.profile_id = existing
except Exception as exc:
logger.warning("Error checking duplicate identity: %s", exc)
# Ensure profile definition and role assignment
from antigravity_provider.router.auto_assigner import AutoAssigner
AutoAssigner.ensure_profile_definition("antigravity", self.profile_id)
self.status = "completed"
# Trigger background probe without blocking
try:
from antigravity_provider.router.account_probe_service import AccountProbeService
AccountProbeService.get().schedule("antigravity", self.profile_id, force=True)
except Exception:
pass
return True, "Авторизация успешно завершена через agy CLI", {
"status": "completed",
"profile_id": self.profile_id,
"email": email or "Н/Д (почта не передана провайдером)",
}
elapsed = int(now - self.created_at)
return True, "Ожидание завершения авторизации в терминале...", {
"status": "pending",
"profile_id": self.profile_id,
"elapsed_sec": elapsed,
"timeout_sec": self.timeout_sec,
}
_ACTIVE_NATIVE_SESSIONS: dict[str, NativeAgySession] = {}
def get_native_agy_session(session_id: str) -> NativeAgySession | None:
return _ACTIVE_NATIVE_SESSIONS.get(session_id)
def start_native_agy_login(
profile_id: str | None = None,
force: bool = False,
) -> tuple[bool, str, dict[str, Any]]:
"""Launch agy CLI in a new host terminal window with isolated HOME pointing to profile directory.
Returns:
(ok, message, data)
"""
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.profile_manager import ProfileAuthManager
slot = profile_id or AutoAssigner.find_free_slot("antigravity") or "ag-1"
valid, reason = AutoAssigner.validate_slot("antigravity", slot)
if not valid:
return False, reason, {"profile_id": slot}
# P0-2.2: Do not overwrite occupied slot without explicit confirmation
if not force:
status = ProfileAuthManager.get_profile_status("antigravity", slot)
if status.get("authenticated"):
email_info = status.get("email_masked") or "Google Account"
return False, f"Слот {slot} уже занят аккаунтом ({email_info}). Подтвердите перезапись учётных данных.", {
"confirmation_required": True,
"profile_id": slot,
"email": email_info,
}
profile_dir = get_profile_env_dir(slot)
try:
profile_dir.mkdir(parents=True, exist_ok=True)
os.chmod(profile_dir, 0o700)
except OSError as exc:
return False, f"Не удалось создать каталог профиля {profile_dir}: {exc}", {"profile_id": slot}
try:
agy_exe = get_agy_exe()
except Exception as exc:
return False, str(exc), {"profile_id": slot, "home": str(profile_dir)}
term_cmd, err_msg, checked = find_terminal_emulator(slot, agy_exe, profile_dir)
if err_msg or not term_cmd:
return False, err_msg or "Терминал не найден", {
"profile_id": slot,
"home": str(profile_dir),
"checked_terminals": checked,
}
env = build_safe_subprocess_env(
overrides={
"HOME": str(profile_dir),
"USERPROFILE": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
)
try:
subprocess.Popen(
term_cmd,
env=env,
cwd=str(profile_dir),
stdin=None,
stdout=None,
stderr=None,
)
except Exception as launch_exc:
return False, f"Не удалось запустить терминал ({term_cmd[0]}): {launch_exc}. Запуск с HOME={profile_dir}", {
"profile_id": slot,
"home": str(profile_dir),
"checked_terminals": checked,
}
session = NativeAgySession(profile_id=slot, profile_dir=profile_dir)
session.terminal_cmd = term_cmd
_ACTIVE_NATIVE_SESSIONS[session.session_id] = session
logger.info("Started native agy login session=%s profile=%s in terminal=%s", session.session_id, slot, term_cmd[0])
return True, "Терминал успешно запущен. Пройдите авторизацию в открывшемся окне.", {
"session_id": session.session_id,
"profile_id": slot,
"profile_dir": str(profile_dir),
"terminal_cmd": term_cmd[0],
"timeout_sec": session.timeout_sec,
}
def poll_native_agy_login(session_id: str) -> tuple[bool, str, dict[str, Any]]:
"""Check the status of an ongoing native agy login session."""
session = get_native_agy_session(session_id)
if not session:
return False, "Сессия авторизации не найдена или уже завершена", {"status": "not_found"}
return session.check_status()
def cancel_native_agy_login(session_id: str) -> tuple[bool, str]:
"""Cancel an ongoing native agy login session."""
session = get_native_agy_session(session_id)
if session:
session.status = "cancelled"
session.error_msg = "Авторизация отменена пользователем"
return True, "Авторизация отменена"
return False, "Сессия не найдена"
def hidden_process_kwargs() -> dict:
"""Флаги запуска подпроцесса без видимого окна консоли (только Windows).
Хаб — оконное приложение без консоли, поэтому каждый запуск консольного
exe (agy.exe и прочие) открывал отдельное чёрное окно. Пока проверка шла
по нажатию, это было незаметно. После A50 проверка аккаунтов запускается
сама раз в минуту, и окна стали появляться постоянно, мешая работе.
Применять ко всем ФОНОВЫМ вызовам. Для входа по OAuth окно нужно
видимым — там сознательно используется CREATE_NEW_CONSOLE.
"""
if os.name != "nt":
return {}
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
return {"creationflags": flags, "startupinfo": startupinfo}
def build_safe_subprocess_env(
base_env: dict[str, str] | None = None,
allow_extra_keys: set[str] | list[str] | None = None,
overrides: dict[str, str] | None = None,
) -> dict[str, str]:
"""Construct an explicitly isolated and sanitized environment dictionary for child subprocesses.
Copies ONLY explicitly permitted system variables from base_env (defaulting to os.environ),
strips out any provider API keys or secrets, and applies explicit overrides.
"""
src = os.environ if base_env is None else base_env
allow = set(k.upper() for k in SAFE_SYSTEM_ENV_VARS)
if allow_extra_keys:
allow.update(k.upper() for k in allow_extra_keys)
clean_env: dict[str, str] = {}
for k, v in src.items():
k_upper = k.upper()
k_lower = k.lower()
if k_upper in allow:
# Strip secret-bearing keys even if matching an allow pattern unless explicitly in allow_extra_keys
if not allow_extra_keys or k not in allow_extra_keys:
if any(pat in k_lower for pat in BLOCKED_SECRET_PATTERNS):
continue
clean_env[k] = v
if overrides:
for k, v in overrides.items():
clean_env[k] = str(v)
return clean_env
def _safe_env() -> dict[str, str]:
"""Backward-compatible wrapper for build_safe_subprocess_env."""
return build_safe_subprocess_env()
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def agy_generate(
request: dict[str, Any],
custom_env: dict[str, str] | None = None,
profile_id: str | None = None,
) -> dict[str, Any]:
"""Execute a chat completion via the ``agy`` subprocess."""
exe = get_agy_exe()
timeout = int(request.get("timeout") or 180)
messages = request.get("messages") or []
tools = request.get("tools") if isinstance(request.get("tools"), list) else None
model_raw = str(request.get("model") or "")
# Build the flat text prompt
prompt = serialize_messages(messages, tools)
if not prompt.strip():
prompt = "Continue."
# Resolve model & effort
agy_model = _resolve_model(model_raw)
reasoning_effort = request.get("reasoning_effort")
if reasoning_effort is None and isinstance(request.get("reasoning"), dict):
reasoning_effort = request["reasoning"].get("effort")
if reasoning_effort is None and isinstance(request.get("extra_body"), dict):
eb = request["extra_body"]
if isinstance(eb.get("reasoning"), dict):
reasoning_effort = eb["reasoning"].get("effort")
agy_effort = _resolve_effort(reasoning_effort)
# Smart effort selection based on model capabilities.
# Some models (gemini) REQUIRE --effort, others (claude, gpt) DON'T SUPPORT it.
supported = _model_supported_efforts(agy_model, profile_id=profile_id)
if supported:
# Model supports specific efforts
if not agy_effort:
# Default: pick "low" if available, else first sorted effort
agy_effort = "low" if "low" in supported else sorted(supported)[0]
elif agy_effort not in supported:
# Requested effort not supported — pick closest
logger.warning(
"effort %r not supported for %s (available: %s), using fallback",
agy_effort, agy_model, sorted(supported),
)
agy_effort = "low" if "low" in supported else sorted(supported)[0]
else:
# Model has NO effort entries — don't pass --effort at all
agy_effort = ""
# Build command.
# Prompt is delivered via stdin (--input-format text), NOT via -p argv.
# This avoids Windows CreateProcess 32767-char command-line limit.
cmd = [
exe,
"--input-format", "text",
"--output-format", "json",
"--dangerously-skip-permissions",
"--disable-slash-commands",
"--print-timeout", f"{timeout}s",
]
# Каталог agy отдаёт идентификаторы с уровнем усилия: gemini-3.7-flash-high,
# -medium, -low. В профиле же хранится голое имя, и вызов уходил с пустым
# --effort: agy отвечал «gemini-3.7-flash requires --effort (available: low,
# medium, high)» и работа не начиналась.
if agy_model and not agy_effort:
base, _, tail = str(agy_model).rpartition("-")
if base and tail in ("low", "medium", "high"):
# Уровень зашит в самом имени — отделяем его.
agy_model, agy_effort = base, tail
else:
known = _AGY_EFFORT_MAP.get(agy_model) or set()
if known:
# Берём средний уровень, если он есть: он и по названию средний,
# и по расходу квоты. Иначе — любой доступный, по порядку.
for candidate in ("medium", "high", "low"):
if candidate in known:
agy_effort = candidate
break
if not agy_effort:
agy_effort = sorted(known)[0]
logger.info(
"agy_generate: у модели %s не задан уровень усилия, выбран %s из %s",
agy_model, agy_effort, sorted(known),
)
if agy_model:
cmd.extend(["--model", agy_model])
if agy_effort:
cmd.extend(["--effort", agy_effort])
logger.info(
"agy_generate: model=%s effort=%s prompt_len=%d",
agy_model, agy_effort, len(prompt),
)
# Pre-flight: verify exe still exists
if not os.path.isfile(exe):
return _error_completion(
model_raw,
f"agy binary does not exist at {exe}",
)
# --- run subprocess ---
t0 = time.monotonic()
try:
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=timeout + 30,
encoding="utf-8",
errors="replace",
env=custom_env if custom_env is not None else build_safe_subprocess_env(),
**hidden_process_kwargs(),
)
except subprocess.TimeoutExpired:
return _error_completion(model_raw, "agy subprocess timed out")
except FileNotFoundError as exc:
return _error_completion(model_raw, f"agy binary not found: {exc}")
except OSError as exc:
return _error_completion(model_raw, f"agy OS error: {exc}")
except Exception as exc:
return _error_completion(
model_raw, f"agy subprocess error: {type(exc).__name__}: {exc}"
)
elapsed = time.monotonic() - t0
# --- parse stdout ---
stdout = result.stdout.strip()
if not stdout:
stderr = (result.stderr or "").strip()[:500]
return _error_completion(
model_raw,
f"agy returned empty output (exit {result.returncode}): {stderr}",
)
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return _error_completion(
model_raw, f"agy returned invalid JSON: {stdout[:500]}"
)
status = data.get("status", "")
if status == "ERROR":
return _error_completion(
model_raw, f"agy error: {data.get('error', 'unknown')}"
)
response_text = data.get("response", "")
usage = data.get("usage") or {}
logger.info(
"agy_generate: status=%s elapsed=%.1fs tokens=%s",
status, elapsed, usage.get("total_tokens"),
)
# --- try to extract tool calls ---
tool_calls = _extract_tool_calls(response_text)
message: dict[str, Any] = {
"role": "assistant",
"content": (
_strip_tool_call_blocks(response_text)
if tool_calls
else response_text
) or None,
}
if tool_calls:
message["tool_calls"] = tool_calls
return {
"id": "chatcmpl-agy-" + uuid.uuid4().hex[:16],
"object": "chat.completion",
"created": int(time.time()),
"model": model_raw or "google-antigravity/unknown",
"choices": [
{
"index": 0,
"message": message,
"finish_reason": (
"tool_calls" if tool_calls else "stop"
),
}
],
"usage": {
"prompt_tokens": int(usage.get("input_tokens") or 0),
"completion_tokens": int(usage.get("output_tokens") or 0),
"total_tokens": int(usage.get("total_tokens") or 0),
},
}
def _error_completion(model: str, error_msg: str) -> dict[str, Any]:
"""Build a structured provider error object for router failover."""
logger.error("agy_generate error: %s", error_msg)
return {
"error": {
"message": f"Antigravity error: {error_msg}",
"type": "provider_error",
"model": model or "google-antigravity/unknown",
}
}