Merge remote-tracking branch 'origin/review/a35-a37-verified' into HEAD
This commit is contained in:
commit
1d26608887
14 changed files with 2245 additions and 131 deletions
|
|
@ -27,17 +27,45 @@ def _error_message(exc: Exception) -> str:
|
||||||
def antigravity_llm_execution(**kwargs: Any) -> Any:
|
def antigravity_llm_execution(**kwargs: Any) -> Any:
|
||||||
request = kwargs.get("request") or {}
|
request = kwargs.get("request") or {}
|
||||||
next_call = kwargs.get("next_call")
|
next_call = kwargs.get("next_call")
|
||||||
provider = kwargs.get("provider")
|
provider = kwargs.get("provider") or request.get("provider")
|
||||||
|
model = kwargs.get("model") or request.get("model")
|
||||||
|
session_id = kwargs.get("session_id") or request.get("session_id")
|
||||||
|
|
||||||
# 1. Try routing through Multi-Provider Account Router if enabled AND role is determined
|
# 1. Try routing through Multi-Provider Account Router if enabled
|
||||||
try:
|
try:
|
||||||
from .router import get_router_engine
|
from .router import get_router_engine
|
||||||
engine = get_router_engine()
|
engine = get_router_engine()
|
||||||
if engine.config.enabled:
|
if engine.config.enabled:
|
||||||
|
# Check if router has any configured and enabled profiles.
|
||||||
|
# Empty configuration must not break Hermes and falls through cleanly.
|
||||||
|
has_active_profiles = bool(
|
||||||
|
engine.config.profiles and any(p.enabled for p in engine.config.profiles.values())
|
||||||
|
)
|
||||||
|
if not has_active_profiles:
|
||||||
|
logger.info("Router has no active profiles; passing call downstream to Hermes")
|
||||||
|
if callable(next_call):
|
||||||
|
return next_call(request)
|
||||||
|
return request
|
||||||
|
|
||||||
role = kwargs.get("role") or request.get("role")
|
role = kwargs.get("role") or request.get("role")
|
||||||
if not role and isinstance(request.get("metadata"), dict):
|
if not role and isinstance(request.get("metadata"), dict):
|
||||||
role = request["metadata"].get("role")
|
role = request["metadata"].get("role")
|
||||||
resolved_role = engine.resolve_role(request, explicit_role=role)
|
|
||||||
|
if provider and not role and not any(p.provider == provider for p in engine.config.profiles.values()):
|
||||||
|
logger.info("Explicit provider %r not managed by router; passing call downstream to Hermes", provider)
|
||||||
|
if callable(next_call):
|
||||||
|
return next_call(request)
|
||||||
|
return request
|
||||||
|
|
||||||
|
allow_default_fallback = not provider or provider == "antigravity"
|
||||||
|
resolved_role, resolution_source = engine.resolve_role_with_source(
|
||||||
|
request,
|
||||||
|
explicit_role=role,
|
||||||
|
model=model,
|
||||||
|
provider=provider,
|
||||||
|
session_id=session_id,
|
||||||
|
fallback_to_default=allow_default_fallback,
|
||||||
|
)
|
||||||
|
|
||||||
if not resolved_role:
|
if not resolved_role:
|
||||||
if callable(next_call):
|
if callable(next_call):
|
||||||
|
|
@ -45,8 +73,16 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
|
||||||
return request
|
return request
|
||||||
|
|
||||||
if resolved_role:
|
if resolved_role:
|
||||||
session_id = kwargs.get("session_id") or request.get("session_id")
|
source_labels = {
|
||||||
|
"explicit": "явная роль",
|
||||||
|
"model_match": "по модели и провайдеру",
|
||||||
|
"session_affinity": "по устойчивости сессии",
|
||||||
|
"default_fallback": "роль по умолчанию",
|
||||||
|
}
|
||||||
|
source_label = source_labels.get(resolution_source, resolution_source)
|
||||||
|
|
||||||
completion = engine.route_request(request, role=resolved_role, session_id=session_id)
|
completion = engine.route_request(request, role=resolved_role, session_id=session_id)
|
||||||
|
|
||||||
# Исчерпанная цепочка — это отказ роутера, а не ответ модели.
|
# Исчерпанная цепочка — это отказ роутера, а не ответ модели.
|
||||||
# Возвращать её текст Гермесу нельзя: он подменит собой настоящий
|
# Возвращать её текст Гермесу нельзя: он подменит собой настоящий
|
||||||
# ответ провайдера, который Гермес выбрал бы сам, и пользователь
|
# ответ провайдера, который Гермес выбрал бы сам, и пользователь
|
||||||
|
|
@ -58,9 +94,41 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
|
||||||
resolved_role,
|
resolved_role,
|
||||||
completion.get("failover_trail"),
|
completion.get("failover_trail"),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
EventLogService.get().log(
|
||||||
|
category="routing",
|
||||||
|
message=f"Цепочка для роли '{resolved_role}' исчерпана; вызов передан штатному обработчику Hermes",
|
||||||
|
details=f"Признак выбора роли: {source_label}",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if callable(next_call):
|
if callable(next_call):
|
||||||
return next_call(request)
|
return next_call(request)
|
||||||
return openai_completion_object(completion)
|
return openai_completion_object(completion)
|
||||||
|
|
||||||
|
# Log successful Hermes routing event with factual role origin and profile
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
meta = completion.get("router_metadata", {}) if isinstance(completion, dict) else {}
|
||||||
|
served_profile = meta.get("profile_id", "default")
|
||||||
|
served_provider = meta.get("provider", provider or "unknown")
|
||||||
|
served_model = (
|
||||||
|
meta.get("selected_model")
|
||||||
|
or (meta.get("selection_trace") or {}).get("selected_model")
|
||||||
|
or model
|
||||||
|
or "default"
|
||||||
|
)
|
||||||
|
EventLogService.get().log(
|
||||||
|
category="routing",
|
||||||
|
message=f"Запрос Hermes направлен роли '{resolved_role}' ({source_label})",
|
||||||
|
details=f"Профиль: '{served_profile}' ({served_provider}), модель: {served_model}",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if isinstance(completion, dict) and "error" in completion and not completion.get("choices"):
|
if isinstance(completion, dict) and "error" in completion and not completion.get("choices"):
|
||||||
err_text = format_antigravity_error(completion.get("error"))
|
err_text = format_antigravity_error(completion.get("error"))
|
||||||
completion = {
|
completion = {
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error')
|
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error')
|
||||||
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)}
|
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)}
|
||||||
|
|
||||||
def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
|
def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]:
|
||||||
# Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали
|
# Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали
|
||||||
# наоборот. Внутри есть костыль, молча исправляющий перестановку, но только
|
# наоборот. Внутри есть костыль, молча исправляющий перестановку, но только
|
||||||
# для antigravity, openai-codex и opencode-go. Для grok, claude и local путь
|
# для antigravity, openai-codex и opencode-go. Для grok, claude и local путь
|
||||||
|
|
@ -118,9 +118,26 @@ def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||||
if auth_p.is_file():
|
if auth_p.is_file():
|
||||||
try:
|
try:
|
||||||
auth_p.unlink()
|
auth_p.unlink()
|
||||||
EventLogService.get().log('account', f'Учетные данные для {profile_id} удалены.', level='warning')
|
EventLogService.get().log(
|
||||||
|
'account',
|
||||||
|
f'Учетные данные для {profile_id} удалены.',
|
||||||
|
level='warning',
|
||||||
|
actor=actor,
|
||||||
|
action='delete_credentials',
|
||||||
|
target_profile=profile_id,
|
||||||
|
outcome='success',
|
||||||
|
)
|
||||||
return True, f"Учетные данные для '{profile_id}' удалены"
|
return True, f"Учетные данные для '{profile_id}' удалены"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
EventLogService.get().log(
|
||||||
|
'account',
|
||||||
|
f'Ошибка удаления учётных данных {profile_id}: {e}',
|
||||||
|
level='error',
|
||||||
|
actor=actor,
|
||||||
|
action='delete_credentials',
|
||||||
|
target_profile=profile_id,
|
||||||
|
outcome='failed',
|
||||||
|
)
|
||||||
return False, f'Ошибка удаления: {e}'
|
return False, f'Ошибка удаления: {e}'
|
||||||
# Отсутствие файла — это не успех удаления. Раньше такой ответ выглядел
|
# Отсутствие файла — это не успех удаления. Раньше такой ответ выглядел
|
||||||
# для пользователя как «сработало», хотя аккаунт оставался подключённым.
|
# для пользователя как «сработало», хотя аккаунт оставался подключённым.
|
||||||
|
|
@ -418,7 +435,7 @@ class ActionExecutor:
|
||||||
"""Shared execution layer for Desktop and Web actions."""
|
"""Shared execution layer for Desktop and Web actions."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None) -> Dict[str, Any]:
|
def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Execute the specified action.
|
Execute the specified action.
|
||||||
If async_runner is provided, long actions will be dispatched to it.
|
If async_runner is provided, long actions will be dispatched to it.
|
||||||
|
|
@ -678,9 +695,62 @@ class ActionExecutor:
|
||||||
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
|
||||||
|
|
||||||
elif action == 'delete_credentials':
|
elif action == 'delete_credentials':
|
||||||
ok, msg = do_delete_credentials(prov, pid)
|
dry_run = bool(data.get('dry_run', False))
|
||||||
|
confirmed = bool(data.get('confirmed', True))
|
||||||
|
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||||||
|
auth_p = get_profile_auth_path(prov, pid)
|
||||||
|
if dry_run:
|
||||||
|
exists = auth_p.is_file()
|
||||||
|
EventLogService.get().log(
|
||||||
|
'security',
|
||||||
|
f'Сухой прогон удаления учётных данных {pid}',
|
||||||
|
actor=actor,
|
||||||
|
action='delete_credentials',
|
||||||
|
target_profile=pid,
|
||||||
|
outcome='dry_run',
|
||||||
|
level='info',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'dry_run': True,
|
||||||
|
'message': f"Сухой прогон: будет удалён файл {auth_p.name} ({'найден' if exists else 'не найден'})",
|
||||||
|
'data': {'path': str(auth_p), 'exists': exists, 'provider': prov, 'profile_id': pid},
|
||||||
|
}
|
||||||
|
if not confirmed:
|
||||||
|
EventLogService.get().log(
|
||||||
|
'security',
|
||||||
|
f'Запрошено подтверждение удаления учётных данных {pid}',
|
||||||
|
actor=actor,
|
||||||
|
action='delete_credentials',
|
||||||
|
target_profile=pid,
|
||||||
|
outcome='denied',
|
||||||
|
level='warning',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'confirmation_required': True,
|
||||||
|
'message': f"Требуется подтверждение удаления учётных данных для '{pid}'",
|
||||||
|
'data': {'path': str(auth_p), 'provider': prov, 'profile_id': pid},
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, msg = do_delete_credentials(prov, pid, actor=actor)
|
||||||
return {'ok': ok, 'message': msg}
|
return {'ok': ok, 'message': msg}
|
||||||
|
|
||||||
|
elif action == 'dry_run_delete':
|
||||||
|
from antigravity_provider.router.security_guard import get_workspace_guard
|
||||||
|
targets = data.get('paths') or ([data.get('path')] if data.get('path') else [])
|
||||||
|
guard = get_workspace_guard()
|
||||||
|
res = guard.dry_run_deletion(targets)
|
||||||
|
EventLogService.get().log(
|
||||||
|
'security',
|
||||||
|
f"Сухой прогон удаления: {res['total_files']} файлов, {res['total_dirs']} каталогов (риск: {res['risk_level']})",
|
||||||
|
actor=actor,
|
||||||
|
action='dry_run_delete',
|
||||||
|
outcome='dry_run',
|
||||||
|
level='info',
|
||||||
|
)
|
||||||
|
return {'ok': True, 'message': 'Сухой прогон выполнен', 'data': res}
|
||||||
|
|
||||||
elif action == 'auto_assign_all':
|
elif action == 'auto_assign_all':
|
||||||
if async_runner:
|
if async_runner:
|
||||||
async_runner(lambda: AutoAssigner.auto_assign_all(), 'AutoAssignAll')
|
async_runner(lambda: AutoAssigner.auto_assign_all(), 'AutoAssignAll')
|
||||||
|
|
|
||||||
|
|
@ -231,12 +231,57 @@ class ModelRegistry:
|
||||||
quota_bucket="antigravity.gemini",
|
quota_bucket="antigravity.gemini",
|
||||||
quality_tier=5,
|
quality_tier=5,
|
||||||
),
|
),
|
||||||
|
ModelDescriptor(
|
||||||
|
model_id="google-antigravity/gemini-3.1-pro-high",
|
||||||
|
display_name="Gemini 3.1 Pro (High)",
|
||||||
|
provider="antigravity",
|
||||||
|
family="gemini",
|
||||||
|
capabilities=["coding", "reasoning", "tools", "structured_output", "long_context", "planning", "security_analysis", "developer-2", "reviewer"],
|
||||||
|
context_window=1000000,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
latency_class="medium",
|
||||||
|
cost_input_per_m=1.25,
|
||||||
|
cost_output_per_m=5.0,
|
||||||
|
quota_bucket="antigravity.gemini",
|
||||||
|
quality_tier=5,
|
||||||
|
),
|
||||||
|
ModelDescriptor(
|
||||||
|
model_id="google-antigravity/gemini-3.1-pro-low",
|
||||||
|
display_name="Gemini 3.1 Pro (Low)",
|
||||||
|
provider="antigravity",
|
||||||
|
family="gemini",
|
||||||
|
capabilities=["coding", "reasoning", "tools", "structured_output", "long_context", "developer-2"],
|
||||||
|
context_window=1000000,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
latency_class="low",
|
||||||
|
cost_input_per_m=1.25,
|
||||||
|
cost_output_per_m=5.0,
|
||||||
|
quota_bucket="antigravity.gemini",
|
||||||
|
quality_tier=4,
|
||||||
|
),
|
||||||
ModelDescriptor(
|
ModelDescriptor(
|
||||||
model_id="google-antigravity/gemini-3.7-flash",
|
model_id="google-antigravity/gemini-3.7-flash",
|
||||||
display_name="Gemini 3.7 Flash",
|
display_name="Gemini 3.7 Flash",
|
||||||
provider="antigravity",
|
provider="antigravity",
|
||||||
family="gemini",
|
family="gemini",
|
||||||
capabilities=["coding", "reasoning", "tools", "structured_output", "classification", "routing", "long_context"],
|
capabilities=["coding", "reasoning", "tools", "structured_output", "classification", "routing", "long_context", "developer-1"],
|
||||||
|
context_window=1000000,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
latency_class="ultra_low",
|
||||||
|
cost_input_per_m=0.15,
|
||||||
|
cost_output_per_m=0.6,
|
||||||
|
quota_bucket="antigravity.gemini",
|
||||||
|
quality_tier=4,
|
||||||
|
),
|
||||||
|
ModelDescriptor(
|
||||||
|
model_id="google-antigravity/gemini-3.7-flash-high",
|
||||||
|
display_name="Gemini 3.7 Flash (High)",
|
||||||
|
provider="antigravity",
|
||||||
|
family="gemini",
|
||||||
|
capabilities=["coding", "reasoning", "tools", "structured_output", "classification", "routing", "long_context", "developer-1"],
|
||||||
context_window=1000000,
|
context_window=1000000,
|
||||||
supports_tools=True,
|
supports_tools=True,
|
||||||
supports_reasoning=True,
|
supports_reasoning=True,
|
||||||
|
|
@ -262,6 +307,51 @@ class ModelRegistry:
|
||||||
quality_tier=5,
|
quality_tier=5,
|
||||||
),
|
),
|
||||||
# Google Antigravity (Claude family inside AGY)
|
# Google Antigravity (Claude family inside AGY)
|
||||||
|
ModelDescriptor(
|
||||||
|
model_id="google-antigravity/claude-opus-4-6-thinking",
|
||||||
|
display_name="Claude Opus 4.6 (Thinking)",
|
||||||
|
provider="antigravity",
|
||||||
|
family="claude",
|
||||||
|
capabilities=["coding", "reasoning", "tools", "structured_output", "security_analysis", "planning", "code-reviewer", "reviewer"],
|
||||||
|
context_window=200000,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
latency_class="high",
|
||||||
|
cost_input_per_m=15.0,
|
||||||
|
cost_output_per_m=75.0,
|
||||||
|
quota_bucket="antigravity.claude",
|
||||||
|
quality_tier=5,
|
||||||
|
),
|
||||||
|
ModelDescriptor(
|
||||||
|
model_id="google-antigravity/claude-opus-4-6",
|
||||||
|
display_name="Claude Opus 4.6 (AGY)",
|
||||||
|
provider="antigravity",
|
||||||
|
family="claude",
|
||||||
|
capabilities=["coding", "reasoning", "tools", "structured_output", "security_analysis", "planning", "code-reviewer", "reviewer"],
|
||||||
|
context_window=200000,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
latency_class="high",
|
||||||
|
cost_input_per_m=15.0,
|
||||||
|
cost_output_per_m=75.0,
|
||||||
|
quota_bucket="antigravity.claude",
|
||||||
|
quality_tier=5,
|
||||||
|
),
|
||||||
|
ModelDescriptor(
|
||||||
|
model_id="google-antigravity/claude-sonnet-4-6",
|
||||||
|
display_name="Claude Sonnet 4.6 (Thinking)",
|
||||||
|
provider="antigravity",
|
||||||
|
family="claude",
|
||||||
|
capabilities=["coding", "reasoning", "tools", "structured_output", "security_analysis", "planning"],
|
||||||
|
context_window=200000,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_reasoning=True,
|
||||||
|
latency_class="medium",
|
||||||
|
cost_input_per_m=3.0,
|
||||||
|
cost_output_per_m=15.0,
|
||||||
|
quota_bucket="antigravity.claude",
|
||||||
|
quality_tier=5,
|
||||||
|
),
|
||||||
ModelDescriptor(
|
ModelDescriptor(
|
||||||
model_id="google-antigravity/claude-3-7-sonnet",
|
model_id="google-antigravity/claude-3-7-sonnet",
|
||||||
display_name="Claude 3.7 Sonnet (AGY)",
|
display_name="Claude 3.7 Sonnet (AGY)",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
||||||
is_implemented=True,
|
is_implemented=True,
|
||||||
capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
|
capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"],
|
||||||
fallback_capabilities=["coding"],
|
fallback_capabilities=["coding"],
|
||||||
default_preferred_chain=["codex-worker-1", "claude-worker-1", "ag-w1", "opengo-1"],
|
default_preferred_chain=["ag-w1", "codex-worker-1", "claude-worker-1", "opengo-1"],
|
||||||
|
default_model="gemini-3.7-flash",
|
||||||
max_failover_attempts=3,
|
max_failover_attempts=3,
|
||||||
tier="core",
|
tier="core",
|
||||||
),
|
),
|
||||||
|
|
@ -64,7 +65,8 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
||||||
is_implemented=True,
|
is_implemented=True,
|
||||||
capabilities=["coding", "developer-2", "reviewer", "tools"],
|
capabilities=["coding", "developer-2", "reviewer", "tools"],
|
||||||
fallback_capabilities=["coding", "reviewer"],
|
fallback_capabilities=["coding", "reviewer"],
|
||||||
default_preferred_chain=["ag-w1", "grok-worker-1", "codex-worker-2", "opengo-3"],
|
default_preferred_chain=["ag-w2", "codex-worker-2", "grok-worker-1", "opengo-3"],
|
||||||
|
default_model="gemini-3.1-pro-high",
|
||||||
max_failover_attempts=3,
|
max_failover_attempts=3,
|
||||||
tier="core",
|
tier="core",
|
||||||
),
|
),
|
||||||
|
|
@ -76,7 +78,8 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
||||||
is_implemented=True,
|
is_implemented=True,
|
||||||
capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
|
capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"],
|
||||||
fallback_capabilities=["reviewer", "coding"],
|
fallback_capabilities=["reviewer", "coding"],
|
||||||
default_preferred_chain=["claude-worker-2", "codex-worker-2", "ag-w2", "opengo-2"],
|
default_preferred_chain=["ag-w3", "ag-w2", "claude-worker-2", "codex-worker-2", "opengo-2"],
|
||||||
|
default_model="claude-opus-4-6-thinking",
|
||||||
max_failover_attempts=3,
|
max_failover_attempts=3,
|
||||||
tier="core",
|
tier="core",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class RolePolicy:
|
||||||
@dataclass
|
@dataclass
|
||||||
class RouterConfig:
|
class RouterConfig:
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
default_role: str = "orchestrator"
|
default_role: str = "manager"
|
||||||
quota_cooldown_seconds: int = 1800 # 30 min default
|
quota_cooldown_seconds: int = 1800 # 30 min default
|
||||||
rate_limit_cooldown_seconds: int = 60 # 1 min default
|
rate_limit_cooldown_seconds: int = 60 # 1 min default
|
||||||
max_failover_attempts: int = 3
|
max_failover_attempts: int = 3
|
||||||
|
|
@ -277,7 +277,7 @@ def get_default_router_config() -> RouterConfig:
|
||||||
|
|
||||||
return RouterConfig(
|
return RouterConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
default_role="orchestrator",
|
default_role="manager",
|
||||||
roles=roles,
|
roles=roles,
|
||||||
profiles=profiles,
|
profiles=profiles,
|
||||||
)
|
)
|
||||||
|
|
@ -343,7 +343,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
|
||||||
}
|
}
|
||||||
|
|
||||||
enabled = bool(r_block.get("enabled", data.get("enabled", True)))
|
enabled = bool(r_block.get("enabled", data.get("enabled", True)))
|
||||||
default_role = str(r_block.get("default_role", data.get("default_role", "orchestrator")))
|
raw_default_role = str(r_block.get("default_role", data.get("default_role", "manager"))).strip().lower()
|
||||||
|
default_role = RoleRegistry.resolve_canonical_role(raw_default_role) if raw_default_role else "manager"
|
||||||
max_failover = int(r_block.get("max_failover_attempts", data.get("max_failover_attempts", 3)))
|
max_failover = int(r_block.get("max_failover_attempts", data.get("max_failover_attempts", 3)))
|
||||||
cooldown_base = int(r_block.get("cooldown_base_seconds", data.get("cooldown_base_seconds", 300)))
|
cooldown_base = int(r_block.get("cooldown_base_seconds", data.get("cooldown_base_seconds", 300)))
|
||||||
cooldown_max = int(r_block.get("cooldown_max_seconds", data.get("cooldown_max_seconds", 3600)))
|
cooldown_max = int(r_block.get("cooldown_max_seconds", data.get("cooldown_max_seconds", 3600)))
|
||||||
|
|
|
||||||
|
|
@ -35,21 +35,117 @@ class RouterEngine:
|
||||||
if self.affinity and hasattr(self.affinity, "ttl_seconds"):
|
if self.affinity and hasattr(self.affinity, "ttl_seconds"):
|
||||||
self.affinity.ttl_seconds = self.config.session_affinity_ttl_seconds
|
self.affinity.ttl_seconds = self.config.session_affinity_ttl_seconds
|
||||||
|
|
||||||
def resolve_role(self, request: Dict[str, Any], explicit_role: Optional[str] = None) -> Optional[str]:
|
def resolve_role_with_source(
|
||||||
"""Determine logical role from explicit parameter, request payload, or metadata.
|
self,
|
||||||
|
request: Dict[str, Any],
|
||||||
|
explicit_role: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
provider: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
fallback_to_default: bool = False,
|
||||||
|
) -> Tuple[Optional[str], str]:
|
||||||
|
"""Determine logical role and factual reason from parameters, configuration, or session.
|
||||||
|
|
||||||
Returns None if role cannot be reliably determined (no guessing from prompts).
|
Resolution order:
|
||||||
|
1. Explicit role in parameters, request, or metadata -> ("role", "explicit")
|
||||||
|
2. By model and provider matched dynamically against router configuration -> ("role", "model_match")
|
||||||
|
3. By session affinity for session_id -> ("role", "session_affinity")
|
||||||
|
4. Configurable default role -> ("default_role", "default_fallback") if fallback_to_default else (None, "none")
|
||||||
|
|
||||||
|
Zero prompt guessing.
|
||||||
"""
|
"""
|
||||||
if explicit_role:
|
from .role_registry import RoleRegistry
|
||||||
return explicit_role.strip().lower()
|
|
||||||
if "role" in request and request["role"]:
|
# 1. Explicit role
|
||||||
return str(request["role"]).strip().lower()
|
raw_role = explicit_role
|
||||||
if "personality" in request and request["personality"]:
|
if not raw_role and "role" in request and request["role"]:
|
||||||
return str(request["personality"]).strip().lower()
|
raw_role = str(request["role"])
|
||||||
|
if not raw_role and "personality" in request and request["personality"]:
|
||||||
|
raw_role = str(request["personality"])
|
||||||
|
if not raw_role:
|
||||||
metadata = request.get("metadata", {})
|
metadata = request.get("metadata", {})
|
||||||
if isinstance(metadata, dict) and metadata.get("role"):
|
if isinstance(metadata, dict) and metadata.get("role"):
|
||||||
return str(metadata["role"]).strip().lower()
|
raw_role = str(metadata["role"])
|
||||||
return None
|
if raw_role and str(raw_role).strip():
|
||||||
|
canon_role = RoleRegistry.resolve_canonical_role(str(raw_role).strip().lower())
|
||||||
|
return canon_role, "explicit"
|
||||||
|
|
||||||
|
# 2. By model and provider (derived dynamically from router config, zero hardcoded literals)
|
||||||
|
req_model = str(model or request.get("model") or "").strip()
|
||||||
|
req_prov = str(provider or request.get("provider") or "").strip().lower()
|
||||||
|
|
||||||
|
if req_model:
|
||||||
|
import re
|
||||||
|
|
||||||
|
def _clean_m(m: str) -> str:
|
||||||
|
return m.split("/")[-1].strip().lower()
|
||||||
|
|
||||||
|
def _base_m(m: str) -> str:
|
||||||
|
short = _clean_m(m)
|
||||||
|
return re.sub(r"-(high|medium|low|none|thought|thinking)(?:-(high|medium|low|none))?$", "", short)
|
||||||
|
|
||||||
|
req_m_clean = _clean_m(req_model)
|
||||||
|
req_m_base = _base_m(req_model)
|
||||||
|
|
||||||
|
# 2a. Direct match with role default_model
|
||||||
|
for rname, rpolicy in self.config.roles.items():
|
||||||
|
if rpolicy.default_model:
|
||||||
|
def_m_clean = _clean_m(rpolicy.default_model)
|
||||||
|
def_m_base = _base_m(rpolicy.default_model)
|
||||||
|
if req_m_clean == def_m_clean or (req_m_base and req_m_base == def_m_base):
|
||||||
|
if not req_prov:
|
||||||
|
return RoleRegistry.resolve_canonical_role(rname), "model_match"
|
||||||
|
primary_pcfg = self.config.get_profile(rpolicy.preferred_chain[0]) if rpolicy.preferred_chain else None
|
||||||
|
if primary_pcfg and primary_pcfg.provider.lower() == req_prov:
|
||||||
|
return RoleRegistry.resolve_canonical_role(rname), "model_match"
|
||||||
|
|
||||||
|
# 2b. Match with preferred_models of profiles in role's chain
|
||||||
|
for rname, rpolicy in self.config.roles.items():
|
||||||
|
for pid in rpolicy.preferred_chain:
|
||||||
|
pcfg = self.config.get_profile(pid)
|
||||||
|
if pcfg and pcfg.preferred_models:
|
||||||
|
if req_prov and pcfg.provider.lower() != req_prov:
|
||||||
|
continue
|
||||||
|
for pm in pcfg.preferred_models:
|
||||||
|
pm_clean = _clean_m(pm)
|
||||||
|
pm_base = _base_m(pm)
|
||||||
|
if req_m_clean == pm_clean or (req_m_base and req_m_base == pm_base):
|
||||||
|
return RoleRegistry.resolve_canonical_role(rname), "model_match"
|
||||||
|
|
||||||
|
# 3. By session affinity
|
||||||
|
target_session = session_id or self.resolve_session_id(request)
|
||||||
|
if target_session and self.affinity:
|
||||||
|
aff_rec = self.affinity.get_affinity(target_session)
|
||||||
|
if aff_rec and getattr(aff_rec, "role", None):
|
||||||
|
return RoleRegistry.resolve_canonical_role(aff_rec.role), "session_affinity"
|
||||||
|
|
||||||
|
# 4. Default fallback role
|
||||||
|
if fallback_to_default:
|
||||||
|
from .settings_service import get_hub_settings
|
||||||
|
def_role = get_hub_settings().get("default_role") or self.config.default_role or "manager"
|
||||||
|
return RoleRegistry.resolve_canonical_role(str(def_role).strip().lower()), "default_fallback"
|
||||||
|
|
||||||
|
return None, "none"
|
||||||
|
|
||||||
|
def resolve_role(
|
||||||
|
self,
|
||||||
|
request: Dict[str, Any],
|
||||||
|
explicit_role: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
provider: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
fallback_to_default: bool = False,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Determine logical role from explicit parameter, request payload, metadata, model/provider, or session."""
|
||||||
|
role, _ = self.resolve_role_with_source(
|
||||||
|
request=request,
|
||||||
|
explicit_role=explicit_role,
|
||||||
|
model=model,
|
||||||
|
provider=provider,
|
||||||
|
session_id=session_id,
|
||||||
|
fallback_to_default=fallback_to_default,
|
||||||
|
)
|
||||||
|
return role
|
||||||
|
|
||||||
def resolve_session_id(self, request: Dict[str, Any], explicit_session_id: Optional[str] = None) -> Optional[str]:
|
def resolve_session_id(self, request: Dict[str, Any], explicit_session_id: Optional[str] = None) -> Optional[str]:
|
||||||
if explicit_session_id:
|
if explicit_session_id:
|
||||||
|
|
@ -69,7 +165,15 @@ class RouterEngine:
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute request with session affinity and role-aware failover."""
|
"""Execute request with session affinity and role-aware failover."""
|
||||||
target_role = self.resolve_role(request, role) or self.config.default_role
|
target_role, _ = self.resolve_role_with_source(
|
||||||
|
request,
|
||||||
|
explicit_role=role,
|
||||||
|
model=request.get("model"),
|
||||||
|
provider=request.get("provider"),
|
||||||
|
session_id=session_id,
|
||||||
|
fallback_to_default=True,
|
||||||
|
)
|
||||||
|
target_role = target_role or self.config.default_role or "manager"
|
||||||
target_session = self.resolve_session_id(request, session_id)
|
target_session = self.resolve_session_id(request, session_id)
|
||||||
role_policy = self.config.get_role_policy(target_role)
|
role_policy = self.config.get_role_policy(target_role)
|
||||||
|
|
||||||
|
|
|
||||||
573
src/antigravity_provider/router/security_guard.py
Normal file
573
src/antigravity_provider/router/security_guard.py
Normal file
|
|
@ -0,0 +1,573 @@
|
||||||
|
"""Security perimeter, boundary enforcement, credential protection, and forensic guards for Hermes Hub."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from antigravity_provider import paths
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Exceptions
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityViolationError(Exception):
|
||||||
|
"""Base exception for security perimeter violations."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, safe_alternative: Optional[str] = None, violation_type: str = "general"):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.safe_alternative = safe_alternative
|
||||||
|
self.violation_type = violation_type
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryViolationError(SecurityViolationError):
|
||||||
|
"""Raised when an operation attempts to access or mutate paths outside allowed workspace."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, safe_alternative: Optional[str] = None):
|
||||||
|
super().__init__(message, safe_alternative=safe_alternative, violation_type="boundary")
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialProtectionError(SecurityViolationError):
|
||||||
|
"""Raised when an operation attempts to directly delete or corrupt protected credential files."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, safe_alternative: Optional[str] = None):
|
||||||
|
super().__init__(message, safe_alternative=safe_alternative, violation_type="credentials")
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkBoundaryViolationError(SecurityViolationError):
|
||||||
|
"""Raised when an outbound network request targets a host not in the allowed destination whitelist."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, safe_alternative: Optional[str] = None):
|
||||||
|
super().__init__(message, safe_alternative=safe_alternative, violation_type="network")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Secret Scrubbing Utilities
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
BLOCKED_KEY_SUBSTRINGS: tuple[str, ...] = (
|
||||||
|
"api_key",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"client_secret",
|
||||||
|
"refresh_token",
|
||||||
|
"access_token",
|
||||||
|
"private_key",
|
||||||
|
"jwt",
|
||||||
|
"auth_token",
|
||||||
|
"credential",
|
||||||
|
"bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_BEARER_PATTERN = re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+", re.IGNORECASE)
|
||||||
|
ACCESS_TOKEN_QUERY_PATTERN = re.compile(r"(access_token=)[^&]+", re.IGNORECASE)
|
||||||
|
API_KEY_HEADER_PATTERN = re.compile(r"([A-Za-z0-9_-]*(?:api[_-]?key|token|secret|password)\s*[:=]\s*)[A-Za-z0-9._~+/-]+", re.IGNORECASE)
|
||||||
|
GENERIC_SECRET_PATTERNS = [
|
||||||
|
re.compile(r"sk-[A-Za-z0-9_-]{10,}", re.IGNORECASE),
|
||||||
|
re.compile(r"gh[opsu]_[A-Za-z0-9_-]{10,}", re.IGNORECASE),
|
||||||
|
re.compile(r"xox[baprs]-[A-Za-z0-9_-]{10,}", re.IGNORECASE),
|
||||||
|
re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9._~+/-]{10,}", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_secrets(data: Any) -> Any:
|
||||||
|
"""Recursively scrub credentials, API keys, and authorization tokens from strings, dictionaries, and collections."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
result = {}
|
||||||
|
for k, v in data.items():
|
||||||
|
k_lower = str(k).lower()
|
||||||
|
if any(sub in k_lower for sub in BLOCKED_KEY_SUBSTRINGS):
|
||||||
|
result[k] = "***"
|
||||||
|
elif isinstance(v, (dict, list, tuple, set)):
|
||||||
|
result[k] = scrub_secrets(v)
|
||||||
|
elif isinstance(v, str):
|
||||||
|
result[k] = scrub_string(v)
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return [scrub_secrets(item) for item in data]
|
||||||
|
elif isinstance(data, tuple):
|
||||||
|
return tuple(scrub_secrets(item) for item in data)
|
||||||
|
elif isinstance(data, set):
|
||||||
|
return {scrub_secrets(item) for item in data}
|
||||||
|
elif isinstance(data, str):
|
||||||
|
return scrub_string(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_string(text: str) -> str:
|
||||||
|
"""Mask credentials, bearer tokens, query parameters, and API keys within arbitrary text strings."""
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
return str(text or "")
|
||||||
|
s = AUTH_BEARER_PATTERN.sub("Bearer ***", text)
|
||||||
|
s = ACCESS_TOKEN_QUERY_PATTERN.sub(r"\1***", s)
|
||||||
|
s = API_KEY_HEADER_PATTERN.sub(r"\1***", s)
|
||||||
|
for pat in GENERIC_SECRET_PATTERNS:
|
||||||
|
s = pat.sub("***", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-1: Workspace Boundary & Destructive Operations Guard
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DESTRUCTIVE_COMMAND_NAMES: Set[str] = {
|
||||||
|
"rm",
|
||||||
|
"rmdir",
|
||||||
|
"unlink",
|
||||||
|
"del",
|
||||||
|
"erase",
|
||||||
|
"rd",
|
||||||
|
"remove-item",
|
||||||
|
"ri",
|
||||||
|
"trash-put",
|
||||||
|
"srm",
|
||||||
|
"shred",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceBoundaryGuard:
|
||||||
|
"""Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations."""
|
||||||
|
|
||||||
|
def __init__(self, additional_allowed_roots: Optional[List[Path]] = None):
|
||||||
|
self._custom_roots: List[Path] = [r.resolve() for r in (additional_allowed_roots or [])]
|
||||||
|
|
||||||
|
def get_allowed_roots(self) -> List[Path]:
|
||||||
|
"""Return the canonical list of allowed roots (Project Root, HERMES_HOME, explicit allowed roots)."""
|
||||||
|
roots: List[Path] = []
|
||||||
|
try:
|
||||||
|
repo_root = paths.get_repo_root().resolve()
|
||||||
|
roots.append(repo_root)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
hermes_home = paths.get_hermes_home().resolve()
|
||||||
|
roots.append(hermes_home)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
roots.extend(self._custom_roots)
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
deduped = []
|
||||||
|
seen = set()
|
||||||
|
for r in roots:
|
||||||
|
resolved = r.resolve()
|
||||||
|
if resolved not in seen:
|
||||||
|
seen.add(resolved)
|
||||||
|
deduped.append(resolved)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
def get_forbidden_paths(self) -> List[Path]:
|
||||||
|
"""Return unconditionally protected paths (Credentials, SSH, Core Hub Configs)."""
|
||||||
|
forbidden = []
|
||||||
|
try:
|
||||||
|
hermes_home = paths.get_hermes_home().resolve()
|
||||||
|
# Credential pools
|
||||||
|
forbidden.append((hermes_home / "agy_profiles").resolve())
|
||||||
|
forbidden.append((hermes_home / "codex_profiles").resolve())
|
||||||
|
forbidden.append((hermes_home / "opencode_profiles").resolve())
|
||||||
|
forbidden.append((hermes_home / "claude_profiles").resolve())
|
||||||
|
forbidden.append((hermes_home / "grok_profiles").resolve())
|
||||||
|
forbidden.append((hermes_home / "local_profiles").resolve())
|
||||||
|
# Sensitive config files
|
||||||
|
forbidden.append((hermes_home / "auth.json").resolve())
|
||||||
|
forbidden.append((hermes_home / "hub_settings.json").resolve())
|
||||||
|
forbidden.append((hermes_home / "router_profiles.yaml").resolve())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# User SSH directory
|
||||||
|
try:
|
||||||
|
ssh_dir = (Path.home() / ".ssh").resolve()
|
||||||
|
forbidden.append(ssh_dir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# User global credentials
|
||||||
|
try:
|
||||||
|
user_agy = (Path.home() / ".hermes" / "agy_profiles").resolve()
|
||||||
|
if user_agy not in forbidden:
|
||||||
|
forbidden.append(user_agy)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return forbidden
|
||||||
|
|
||||||
|
def is_inside_allowed_root(self, path: Path | str) -> bool:
|
||||||
|
"""Check whether the given path resolves within any allowed root."""
|
||||||
|
try:
|
||||||
|
target = Path(path).expanduser().resolve()
|
||||||
|
for root in self.get_allowed_roots():
|
||||||
|
try:
|
||||||
|
target.relative_to(root)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Check whether the path touches an unconditionally protected directory or file."""
|
||||||
|
try:
|
||||||
|
target = Path(path).expanduser().resolve()
|
||||||
|
# 1. Exact match or child of forbidden directory
|
||||||
|
for fpath in self.get_forbidden_paths():
|
||||||
|
if target == fpath:
|
||||||
|
return True, f"Путь {target} является защищённым системным ресурсом"
|
||||||
|
try:
|
||||||
|
target.relative_to(fpath)
|
||||||
|
return True, f"Путь {target} находится внутри защищённого каталога учётных данных {fpath}"
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Match critical filenames
|
||||||
|
target_name = target.name.lower()
|
||||||
|
if target_name in {"auth.json", "hub_settings.json", "id_rsa", "id_ed25519", "known_hosts"}:
|
||||||
|
return True, f"Файл {target_name} является защищённым системным файлом"
|
||||||
|
|
||||||
|
# 3. Match .git core internals
|
||||||
|
for part in target.parts:
|
||||||
|
if part.lower() == ".git":
|
||||||
|
# Disallow deleting/modifying .git root, hooks, objects, or config
|
||||||
|
if target_name in {"config", "head", "index"} or "objects" in target.parts or "hooks" in target.parts:
|
||||||
|
return True, "Прямое разрушение служебных файлов git (.git/) запрещено"
|
||||||
|
|
||||||
|
return False, None
|
||||||
|
except Exception as exc:
|
||||||
|
return True, f"Ошибка разрешения пути: {exc}"
|
||||||
|
|
||||||
|
def validate_path(self, path: Path | str, operation: str = "read") -> Tuple[bool, str, Optional[str]]:
|
||||||
|
"""Validate whether an operation on path is permitted.
|
||||||
|
|
||||||
|
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
target = Path(path).expanduser().resolve()
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"Недопустимый путь '{path}': {exc}", "Используйте стандартный относительный путь"
|
||||||
|
|
||||||
|
# Check unconditional forbidden paths for mutating/deleting operations
|
||||||
|
if operation in {"delete", "write", "truncate", "move"}:
|
||||||
|
is_forbidden, forbidden_reason = self.is_forbidden_path(target)
|
||||||
|
if is_forbidden:
|
||||||
|
alt = (
|
||||||
|
"Для удаления аккаунта используйте штатное действие 'delete_credentials' с подтверждением"
|
||||||
|
if "agy_profiles" in str(target) or "auth" in str(target)
|
||||||
|
else "Выполняйте операцию только в рабочей области проекта"
|
||||||
|
)
|
||||||
|
return False, f"Запрещённая операция над защищённым ресурсом: {forbidden_reason}", alt
|
||||||
|
|
||||||
|
# Check boundary containment
|
||||||
|
if not self.is_inside_allowed_root(target):
|
||||||
|
roots_display = ", ".join(str(r) for r in self.get_allowed_roots())
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Путь '{target}' находится за пределами разрешённых рабочих областей: [{roots_display}]",
|
||||||
|
"Переместите целевой файл в каталог проекта или рабочую область агента",
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, "OK", None
|
||||||
|
|
||||||
|
def validate_command(self, cmd_line: str | List[str], cwd: Optional[Path | str] = None) -> Tuple[bool, str, Optional[str]]:
|
||||||
|
"""Inspect and classify a shell command line for destructive or boundary-violating operations.
|
||||||
|
|
||||||
|
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
|
||||||
|
"""
|
||||||
|
if not cmd_line:
|
||||||
|
return True, "OK", None
|
||||||
|
|
||||||
|
# Parse command tokens
|
||||||
|
if isinstance(cmd_line, list):
|
||||||
|
tokens = list(cmd_line)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Windows and POSIX-compatible shlex split
|
||||||
|
tokens = shlex.split(cmd_line, posix=(os.name != "nt"))
|
||||||
|
except Exception:
|
||||||
|
tokens = cmd_line.split()
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
return True, "OK", None
|
||||||
|
|
||||||
|
base_cwd = Path(cwd or paths.get_repo_root()).resolve()
|
||||||
|
cmd_name = Path(tokens[0]).name.lower()
|
||||||
|
if cmd_name.endswith(".exe"):
|
||||||
|
cmd_name = cmd_name[:-4]
|
||||||
|
|
||||||
|
# 1. Check destructive command names
|
||||||
|
if cmd_name in DESTRUCTIVE_COMMAND_NAMES:
|
||||||
|
# Extract target arguments (skip flags starting with - or /)
|
||||||
|
targets = []
|
||||||
|
for arg in tokens[1:]:
|
||||||
|
if arg.startswith("-") or (os.name == "nt" and arg.startswith("/") and len(arg) == 2):
|
||||||
|
continue
|
||||||
|
targets.append(arg)
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
# If no targets specified (e.g. interactive rm), check cwd
|
||||||
|
ok, reason, alt = self.validate_path(base_cwd, operation="delete")
|
||||||
|
if not ok:
|
||||||
|
return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt
|
||||||
|
else:
|
||||||
|
for target_arg in targets:
|
||||||
|
# Тильда и переменные окружения раскрываются ДО проверки.
|
||||||
|
#
|
||||||
|
# Без этого "rm -rf ~/.hermes/agy_profiles" не считался
|
||||||
|
# абсолютным путём, склеивался с каталогом проекта в путь с
|
||||||
|
# буквальным "~" внутри и признавался допустимым. Проверено:
|
||||||
|
# команда с тильдой проходила, та же команда с абсолютным
|
||||||
|
# путём отклонялась. То есть самый естественный способ
|
||||||
|
# написать опасную команду обходил защиту ровно там, ради
|
||||||
|
# чего она и делалась — на каталоге учётных данных.
|
||||||
|
expanded = os.path.expandvars(os.path.expanduser(target_arg))
|
||||||
|
target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded)
|
||||||
|
ok, reason, alt = self.validate_path(target_path, operation="delete")
|
||||||
|
if not ok:
|
||||||
|
return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt
|
||||||
|
|
||||||
|
# 2. Check git clean -fdx / git reset --hard targets
|
||||||
|
if cmd_name == "git":
|
||||||
|
subcmd = tokens[1].lower() if len(tokens) > 1 else ""
|
||||||
|
if subcmd == "clean" and any("-f" in a or "-x" in a or "-d" in a for a in tokens[2:]):
|
||||||
|
# Git clean inside workspace is allowed only if CWD is strictly within project root
|
||||||
|
ok, reason, alt = self.validate_path(base_cwd, operation="delete")
|
||||||
|
if not ok:
|
||||||
|
return False, f"git clean запущен вне проекта: {reason}", alt
|
||||||
|
|
||||||
|
return True, "OK", None
|
||||||
|
|
||||||
|
def dry_run_deletion(self, target_paths: List[Path | str]) -> Dict[str, Any]:
|
||||||
|
"""Perform a safe dry-run assessment of a pending deletion, returning affected files and risk analysis."""
|
||||||
|
files_to_delete: List[Dict[str, Any]] = []
|
||||||
|
dirs_to_delete: List[Dict[str, Any]] = []
|
||||||
|
total_bytes = 0
|
||||||
|
risk_level = "low"
|
||||||
|
has_violations = False
|
||||||
|
reasons: List[str] = []
|
||||||
|
|
||||||
|
for p_raw in target_paths:
|
||||||
|
p = Path(p_raw).expanduser().resolve()
|
||||||
|
ok, reason, alt = self.validate_path(p, operation="delete")
|
||||||
|
if not ok:
|
||||||
|
has_violations = True
|
||||||
|
reasons.append(reason)
|
||||||
|
risk_level = "critical"
|
||||||
|
continue
|
||||||
|
|
||||||
|
if p.is_file():
|
||||||
|
try:
|
||||||
|
sz = p.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
sz = 0
|
||||||
|
total_bytes += sz
|
||||||
|
files_to_delete.append({"path": str(p), "size_bytes": sz, "type": "file"})
|
||||||
|
elif p.is_dir():
|
||||||
|
dir_bytes = 0
|
||||||
|
file_count = 0
|
||||||
|
for root, _, files in os.walk(p):
|
||||||
|
for f in files:
|
||||||
|
fp = Path(root) / f
|
||||||
|
try:
|
||||||
|
fsz = fp.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
fsz = 0
|
||||||
|
dir_bytes += fsz
|
||||||
|
file_count += 1
|
||||||
|
total_bytes += dir_bytes
|
||||||
|
dirs_to_delete.append({"path": str(p), "file_count": file_count, "size_bytes": dir_bytes, "type": "directory"})
|
||||||
|
if file_count > 10:
|
||||||
|
risk_level = "medium" if risk_level != "critical" else risk_level
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"allowed": not has_violations,
|
||||||
|
"risk_level": risk_level,
|
||||||
|
"total_files": len(files_to_delete),
|
||||||
|
"total_dirs": len(dirs_to_delete),
|
||||||
|
"total_bytes": total_bytes,
|
||||||
|
"files": files_to_delete[:100],
|
||||||
|
"directories": dirs_to_delete,
|
||||||
|
"violations": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
def safe_delete_file(self, path: Path | str, dry_run: bool = False) -> Dict[str, Any]:
|
||||||
|
"""Safely delete a single file after boundary and credential checks, supporting dry-run."""
|
||||||
|
target = Path(path).expanduser().resolve()
|
||||||
|
ok, reason, alt = self.validate_path(target, operation="delete")
|
||||||
|
if not ok:
|
||||||
|
raise BoundaryViolationError(reason, safe_alternative=alt)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return self.dry_run_deletion([target])
|
||||||
|
|
||||||
|
if not target.exists():
|
||||||
|
return {"deleted": False, "reason": "File does not exist", "path": str(target)}
|
||||||
|
|
||||||
|
if target.is_dir():
|
||||||
|
raise BoundaryViolationError(f"Путь '{target}' является директорией, используйте safe_delete_dir")
|
||||||
|
|
||||||
|
target.unlink()
|
||||||
|
return {"deleted": True, "path": str(target)}
|
||||||
|
|
||||||
|
def safe_delete_dir(self, path: Path | str, dry_run: bool = False) -> Dict[str, Any]:
|
||||||
|
"""Safely delete a directory tree after boundary and credential checks, supporting dry-run."""
|
||||||
|
target = Path(path).expanduser().resolve()
|
||||||
|
ok, reason, alt = self.validate_path(target, operation="delete")
|
||||||
|
if not ok:
|
||||||
|
raise BoundaryViolationError(reason, safe_alternative=alt)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return self.dry_run_deletion([target])
|
||||||
|
|
||||||
|
if not target.exists():
|
||||||
|
return {"deleted": False, "reason": "Directory does not exist", "path": str(target)}
|
||||||
|
|
||||||
|
if not target.is_dir():
|
||||||
|
raise BoundaryViolationError(f"Путь '{target}' не является директорией")
|
||||||
|
|
||||||
|
shutil.rmtree(target)
|
||||||
|
return {"deleted": True, "path": str(target)}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-3: Network Boundary Guard & Whitelist
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALLOWED_OUTBOUND_HOSTS: Set[str] = {
|
||||||
|
# 1. AI Provider APIs
|
||||||
|
"api.anthropic.com",
|
||||||
|
"generativelanguage.googleapis.com",
|
||||||
|
"api.x.ai",
|
||||||
|
"api.openai.com",
|
||||||
|
"api.deepseek.com",
|
||||||
|
"openrouter.ai",
|
||||||
|
"api.together.xyz",
|
||||||
|
"api.groq.com",
|
||||||
|
"api.mistral.ai",
|
||||||
|
"dashscope.aliyuncs.com",
|
||||||
|
"open.bigmodel.cn",
|
||||||
|
"api.moonshot.cn",
|
||||||
|
# 2. Release & Update Hosts
|
||||||
|
"api.github.com",
|
||||||
|
"github.com",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
"objects.githubusercontent.com",
|
||||||
|
"github-releases.githubusercontent.com",
|
||||||
|
# 3. Local LLMs & Diagnostics
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"0.0.0.0",
|
||||||
|
"::1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkBoundaryGuard:
|
||||||
|
"""Enforces explicit network boundaries for all outgoing Hub HTTP/HTTPS requests."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_host_allowed(cls, host_or_url: str) -> bool:
|
||||||
|
"""Check whether the given hostname or URL target is allowed by the whitelist."""
|
||||||
|
if not host_or_url:
|
||||||
|
return False
|
||||||
|
clean = host_or_url.strip().lower()
|
||||||
|
if "://" in clean:
|
||||||
|
parsed = urllib.parse.urlparse(clean)
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
else:
|
||||||
|
host = clean.split(":")[0].lower()
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Exact match
|
||||||
|
if host in ALLOWED_OUTBOUND_HOSTS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Wildcard subdomain match (e.g. *.githubusercontent.com, *.aliyuncs.com, *.googleapis.com)
|
||||||
|
for allowed in ALLOWED_OUTBOUND_HOSTS:
|
||||||
|
if allowed.startswith("*."):
|
||||||
|
suffix = allowed[1:]
|
||||||
|
if host.endswith(suffix):
|
||||||
|
return True
|
||||||
|
elif host.endswith("." + allowed):
|
||||||
|
# Subdomains of explicitly trusted domains (e.g. download.github.com)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Local subnet / private IP / loopback allow
|
||||||
|
if host.startswith("127.") or host.startswith("10.") or host.startswith("192.168."):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_outbound_url(cls, url: str) -> None:
|
||||||
|
"""Validate destination URL, raising NetworkBoundaryViolationError if host is not permitted."""
|
||||||
|
if not cls.is_host_allowed(url):
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
hostname = parsed.hostname or url
|
||||||
|
raise NetworkBoundaryViolationError(
|
||||||
|
f"Сетевое обращение к хосту '{hostname}' заблокировано сетевой границей хаба.",
|
||||||
|
safe_alternative="Используйте разрешённых провайдеров из белого списка или настройте локальный прокси",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-2: Agent Workspace Guard & Role Credential Separation
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class AgentWorkspaceGuard:
|
||||||
|
"""Provides workspace isolation and scoped credential injection per agent/role."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_agent_workspace_dir(agent_id: str) -> Path:
|
||||||
|
"""Return dedicated workspace directory for an agent, ensuring it exists."""
|
||||||
|
clean_id = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(agent_id).strip()).strip("-").lower()
|
||||||
|
ws_dir = paths.get_hermes_home() / "workspaces" / f"agent-{clean_id}"
|
||||||
|
ws_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return ws_dir.resolve()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_agent_subprocess_env(
|
||||||
|
agent_id: str,
|
||||||
|
role: str,
|
||||||
|
assigned_profile_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Construct an isolated environment containing ONLY the credentials needed for the agent's assigned role."""
|
||||||
|
from antigravity_provider.agy_subprocess import build_safe_subprocess_env
|
||||||
|
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
|
||||||
|
|
||||||
|
ws_dir = AgentWorkspaceGuard.get_agent_workspace_dir(agent_id)
|
||||||
|
overrides: Dict[str, str] = {
|
||||||
|
"HERMES_AGENT_ID": agent_id,
|
||||||
|
"HERMES_AGENT_ROLE": role,
|
||||||
|
"HERMES_AGENT_WORKSPACE": str(ws_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role-scoped profile isolation: inject HOME/USERPROFILE targeting the agent's profile directory
|
||||||
|
if assigned_profile_id:
|
||||||
|
profile_dir = get_profile_env_dir(assigned_profile_id)
|
||||||
|
overrides["HOME"] = str(profile_dir)
|
||||||
|
overrides["USERPROFILE"] = str(profile_dir)
|
||||||
|
overrides["HOMEPATH"] = str(profile_dir)
|
||||||
|
|
||||||
|
return build_safe_subprocess_env(overrides=overrides)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_workspace_guard = WorkspaceBoundaryGuard()
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_guard() -> WorkspaceBoundaryGuard:
|
||||||
|
return _workspace_guard
|
||||||
|
|
@ -23,6 +23,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||||
"quota_threshold_percent": 10.0,
|
"quota_threshold_percent": 10.0,
|
||||||
"quota_threshold_action": "notify",
|
"quota_threshold_action": "notify",
|
||||||
"email_masking_mode": "none",
|
"email_masking_mode": "none",
|
||||||
|
"default_role": "manager",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -105,6 +106,9 @@ def get_hub_settings() -> Dict[str, Any]:
|
||||||
email_mode = "none"
|
email_mode = "none"
|
||||||
merged["email_masking_mode"] = email_mode
|
merged["email_masking_mode"] = email_mode
|
||||||
|
|
||||||
|
default_role = str(merged.get("default_role", "manager")).strip().lower()
|
||||||
|
merged["default_role"] = default_role or "manager"
|
||||||
|
|
||||||
_SETTINGS_CACHE = dict(merged)
|
_SETTINGS_CACHE = dict(merged)
|
||||||
_SETTINGS_CACHE_MTIME = current_mtime
|
_SETTINGS_CACHE_MTIME = current_mtime
|
||||||
_SETTINGS_CACHE_PATH = sfile_str
|
_SETTINGS_CACHE_PATH = sfile_str
|
||||||
|
|
|
||||||
|
|
@ -197,16 +197,22 @@ class SystemReadiness:
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# Event Log Service
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Event Log Service & Forensic Audit
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HubEvent:
|
class HubEvent:
|
||||||
timestamp: str
|
timestamp: str
|
||||||
category: str # account | quota | routing | auth | system
|
category: str # account | quota | routing | auth | system | security
|
||||||
message: str
|
message: str
|
||||||
details: Optional[str] = None
|
details: Optional[str] = None
|
||||||
level: str = "info" # info | warning | error | success
|
level: str = "info" # info | warning | error | success
|
||||||
|
actor: str = "system" # user:web | reviewer:manual | agent:<id> | system
|
||||||
|
action: Optional[str] = None
|
||||||
|
target_profile: Optional[str] = None
|
||||||
|
target_role: Optional[str] = None
|
||||||
|
outcome: str = "success" # success | denied | failed | dry_run
|
||||||
|
|
||||||
|
|
||||||
class EventLogService:
|
class EventLogService:
|
||||||
|
|
@ -226,9 +232,36 @@ class EventLogService:
|
||||||
cls._instance = cls()
|
cls._instance = cls()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def log(self, category: str, message: str, details: Optional[str] = None, level: str = "info"):
|
def log(
|
||||||
|
self,
|
||||||
|
category: str,
|
||||||
|
message: str,
|
||||||
|
details: Optional[str] = None,
|
||||||
|
level: str = "info",
|
||||||
|
actor: str = "system",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
target_profile: Optional[str] = None,
|
||||||
|
target_role: Optional[str] = None,
|
||||||
|
outcome: str = "success",
|
||||||
|
):
|
||||||
|
from antigravity_provider.router.security_guard import scrub_string
|
||||||
|
|
||||||
ts = time.strftime("%H:%M:%S")
|
ts = time.strftime("%H:%M:%S")
|
||||||
event = HubEvent(timestamp=ts, category=category, message=message, details=details, level=level)
|
clean_msg = scrub_string(str(message))
|
||||||
|
clean_details = scrub_string(str(details)) if details is not None else None
|
||||||
|
|
||||||
|
event = HubEvent(
|
||||||
|
timestamp=ts,
|
||||||
|
category=category,
|
||||||
|
message=clean_msg,
|
||||||
|
details=clean_details,
|
||||||
|
level=level,
|
||||||
|
actor=str(actor or "system"),
|
||||||
|
action=str(action) if action else None,
|
||||||
|
target_profile=str(target_profile) if target_profile else None,
|
||||||
|
target_role=str(target_role) if target_role else None,
|
||||||
|
outcome=str(outcome or "success"),
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._events.append(event)
|
self._events.append(event)
|
||||||
# Cap at last 200 events
|
# Cap at last 200 events
|
||||||
|
|
@ -251,8 +284,9 @@ class EventLogService:
|
||||||
log_file = paths.get_log_file()
|
log_file = paths.get_log_file()
|
||||||
clean_msg = sanitize_text(event.message)
|
clean_msg = sanitize_text(event.message)
|
||||||
clean_details = sanitize_text(event.details) if event.details else None
|
clean_details = sanitize_text(event.details) if event.details else None
|
||||||
|
actor_tag = f" [{event.actor}]" if event.actor != "system" else ""
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
with open(log_file, "a", encoding="utf-8") as f:
|
||||||
f.write(f"[{event.timestamp}] [{event.category.upper()}] [{event.level.upper()}] {clean_msg}\n")
|
f.write(f"[{event.timestamp}] [{event.category.upper()}]{actor_tag} [{event.level.upper()}] {clean_msg}\n")
|
||||||
if clean_details:
|
if clean_details:
|
||||||
f.write(f" Details: {clean_details}\n")
|
f.write(f" Details: {clean_details}\n")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,11 @@ app = FastAPI(title="Hermes Hub Web API", version="1.0.0")
|
||||||
# Собственному интерфейсу CORS не нужен: он отдаётся тем же сервером. Список
|
# Собственному интерфейсу CORS не нужен: он отдаётся тем же сервером. Список
|
||||||
# разрешённых источников оставлен настройкой — он понадобится, когда одна
|
# разрешённых источников оставлен настройкой — он понадобится, когда одна
|
||||||
# панель будет смотреть на несколько хабов.
|
# панель будет смотреть на несколько хабов.
|
||||||
|
_raw_cors = str(_bootstrap_settings().get("web_api_allowed_origins", "")).split(",")
|
||||||
_cors_origins = [
|
_cors_origins = [
|
||||||
o.strip()
|
o.strip()
|
||||||
for o in str(_bootstrap_settings().get("web_api_allowed_origins", "")).split(",")
|
for o in _raw_cors
|
||||||
if o.strip()
|
if o.strip() and o.strip() != "*"
|
||||||
]
|
]
|
||||||
if _cors_origins:
|
if _cors_origins:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -66,7 +67,7 @@ if _cors_origins:
|
||||||
allow_origins=_cors_origins,
|
allow_origins=_cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "OPTIONS"],
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
allow_headers=["X-Hub-Token", "Content-Type"],
|
allow_headers=["X-Hub-Token", "Content-Type", "X-Hub-Actor"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -194,6 +195,19 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||||
snap_dict = dataclasses.asdict(snapshot)
|
snap_dict = dataclasses.asdict(snapshot)
|
||||||
snap_dict = sanitize_snapshot(snap_dict)
|
snap_dict = sanitize_snapshot(snap_dict)
|
||||||
|
|
||||||
|
server_host = _web_settings().get("web_api_host", "127.0.0.1")
|
||||||
|
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
|
||||||
|
snap_dict["network_security"] = {
|
||||||
|
"is_external_bind": is_external,
|
||||||
|
"is_tls": False,
|
||||||
|
"host": server_host,
|
||||||
|
"warning": (
|
||||||
|
f"Внимание: Web API привязан к внешнему сетевому интерфейсу ({server_host}) поверх открытого HTTP. Токен авторизации и почты аккаунтов передаются по сети в открытом виде. Рекомендуется использовать HTTPS, VPN или SSH-туннель."
|
||||||
|
if is_external
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||||||
|
|
||||||
@app.post("/api/action")
|
@app.post("/api/action")
|
||||||
|
|
@ -210,7 +224,8 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
|
||||||
def _async_runner(func, name):
|
def _async_runner(func, name):
|
||||||
threading.Thread(target=func, name=name, daemon=True).start()
|
threading.Thread(target=func, name=name, daemon=True).start()
|
||||||
|
|
||||||
result = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner)
|
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
|
||||||
|
result = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner, actor=actor)
|
||||||
if result.get("unknown"):
|
if result.get("unknown"):
|
||||||
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
raise HTTPException(status_code=404, detail="Неизвестное действие")
|
||||||
|
|
||||||
|
|
@ -261,8 +276,10 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
raw = _web_settings()
|
raw = _web_settings()
|
||||||
has_token = bool(raw.get("web_api_token"))
|
has_token = bool(raw.get("web_api_token"))
|
||||||
last_check = UpdateManager.get_last_check_result()
|
last_check = UpdateManager.get_last_check_result()
|
||||||
|
server_host = raw.get("web_api_host", "127.0.0.1")
|
||||||
|
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
|
||||||
settings_out: Dict[str, Any] = {
|
settings_out: Dict[str, Any] = {
|
||||||
"web_api_host": raw.get("web_api_host", "127.0.0.1"),
|
"web_api_host": server_host,
|
||||||
"web_api_port": raw.get("web_api_port", 5800),
|
"web_api_port": raw.get("web_api_port", 5800),
|
||||||
"web_api_token_configured": has_token,
|
"web_api_token_configured": has_token,
|
||||||
"theme": raw.get("theme", "system"),
|
"theme": raw.get("theme", "system"),
|
||||||
|
|
@ -273,6 +290,16 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
"installed_commit": get_installed_commit(),
|
"installed_commit": get_installed_commit(),
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"last_update_check": last_check.to_dict() if last_check else None,
|
"last_update_check": last_check.to_dict() if last_check else None,
|
||||||
|
"network_security": {
|
||||||
|
"is_external_bind": is_external,
|
||||||
|
"is_tls": False,
|
||||||
|
"host": server_host,
|
||||||
|
"warning": (
|
||||||
|
f"Внимание: Web API привязан к внешнему сетевому интерфейсу ({server_host}) поверх открытого HTTP. Токен авторизации и почты аккаунтов передаются по сети в открытом виде. Рекомендуется использовать HTTPS, VPN или SSH-туннель."
|
||||||
|
if is_external
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for k, v in raw.items():
|
for k, v in raw.items():
|
||||||
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
|
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
"""Persistent agents, workflow graph and live execution for Hermes Hub.
|
"""Hermes Hub A30 / A36 workflow runtime and persistence layer."""
|
||||||
|
|
||||||
The router role registry remains the source of truth for logical agents and
|
|
||||||
Provider -> Account -> Model assignment. This module adds the pieces that do
|
|
||||||
not fit the routing schema: Agent Files, editor layout, workflow transitions,
|
|
||||||
execution checkpoints and a bounded event journal.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -17,75 +13,94 @@ from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from antigravity_provider import paths
|
from antigravity_provider import paths
|
||||||
from antigravity_provider.router.router_config import RolePolicy, load_router_config, save_router_config
|
from antigravity_provider.router.router_config import (
|
||||||
|
RolePolicy,
|
||||||
|
load_router_config,
|
||||||
|
save_router_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.workflow")
|
||||||
|
|
||||||
|
|
||||||
AGENT_STATES = {"waiting", "working", "reviewing", "error", "completed", "not_implemented"}
|
_AUTH_BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+", re.IGNORECASE)
|
||||||
EDGE_CONDITIONS = {"SUCCESS", "REVIEW_PASSED", "REVIEW_FAILED", "NEXT", "ERROR", "ALWAYS"}
|
_ACCESS_TOKEN_PARAM_RE = re.compile(r"(access_token=)[^&]+", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_run_data(data: Any) -> Any:
|
||||||
|
"""Sanitize data for saving to workflow_run_state.json, masking secrets and tokens."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
result = {}
|
||||||
|
for k, v in data.items():
|
||||||
|
k_lower = str(k).lower()
|
||||||
|
if any(s in k_lower for s in ["api_key", "token", "password", "secret", "client_secret", "jwt"]):
|
||||||
|
result[k] = "***"
|
||||||
|
elif isinstance(v, (dict, list)):
|
||||||
|
result[k] = sanitize_run_data(v)
|
||||||
|
elif isinstance(v, str):
|
||||||
|
s_val = _AUTH_BEARER_RE.sub("Bearer ***", v)
|
||||||
|
s_val = _ACCESS_TOKEN_PARAM_RE.sub(r"\1***", s_val)
|
||||||
|
result[k] = s_val
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
elif isinstance(data, list):
|
||||||
|
return [sanitize_run_data(item) for item in data]
|
||||||
|
elif isinstance(data, str):
|
||||||
|
s_val = _AUTH_BEARER_RE.sub("Bearer ***", data)
|
||||||
|
s_val = _ACCESS_TOKEN_PARAM_RE.sub(r"\1***", s_val)
|
||||||
|
return s_val
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_run_state(path: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
||||||
|
"""Return the last saved run state from workflow_run_state.json."""
|
||||||
|
target_path = path or paths.get_workflow_run_state_path()
|
||||||
|
if not target_path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(target_path.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
EDGE_CONDITIONS = {
|
||||||
|
"SUCCESS",
|
||||||
|
"ALWAYS",
|
||||||
|
"NEXT",
|
||||||
|
"REVIEW_PASSED",
|
||||||
|
"REVIEW_FAILED",
|
||||||
|
"ERROR",
|
||||||
|
"COMPLETED",
|
||||||
|
"ACCEPTED",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _utc_timestamp() -> str:
|
def _utc_timestamp() -> str:
|
||||||
import datetime
|
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||||
|
|
||||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
|
||||||
|
|
||||||
|
|
||||||
def _slug(value: str) -> str:
|
def _slug(value: str) -> str:
|
||||||
result = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
|
cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip()).strip("-").lower()
|
||||||
return result or f"agent-{uuid.uuid4().hex[:8]}"
|
return cleaned or f"agent-{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
def sanitize_run_data(node: Any) -> Any:
|
def _safe_agent_file(path_str: str, agent_id: str) -> tuple[Path, str]:
|
||||||
"""Recursively strip or mask any credentials or secret keys from workflow run state."""
|
home = paths.get_hermes_home().resolve()
|
||||||
secret_key_substrings = [
|
if not path_str or not path_str.strip():
|
||||||
"api_key", "token", "password", "secret", "jwt", "bearer",
|
relative = f"agents/{_slug(agent_id)}.md"
|
||||||
"access_token", "refresh_token", "client_secret", "authorization",
|
return (home / relative).resolve(), relative
|
||||||
]
|
|
||||||
if isinstance(node, dict):
|
raw = Path(path_str.strip())
|
||||||
sanitized: dict[str, Any] = {}
|
if raw.is_absolute():
|
||||||
for k, v in node.items():
|
resolved = raw.resolve()
|
||||||
k_lower = str(k).lower()
|
|
||||||
if any(s in k_lower for s in secret_key_substrings) and k_lower not in ("auth_status", "author", "auth_required"):
|
|
||||||
sanitized[k] = "***"
|
|
||||||
else:
|
else:
|
||||||
sanitized[k] = sanitize_run_data(v)
|
resolved = (home / raw).resolve()
|
||||||
return sanitized
|
|
||||||
elif isinstance(node, list):
|
|
||||||
return [sanitize_run_data(x) for x in node]
|
|
||||||
elif isinstance(node, str):
|
|
||||||
val = node
|
|
||||||
val = re.sub(r'Bearer\s+[a-zA-Z0-9_\-\.]{8,}', 'Bearer ***', val, flags=re.IGNORECASE)
|
|
||||||
val = re.sub(r'sk-[a-zA-Z0-9_\-]{8,}', 'sk-***', val)
|
|
||||||
val = re.sub(r'gho_[a-zA-Z0-9_\-]{8,}', 'gho_***', val)
|
|
||||||
val = re.sub(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', r'\g<1>***', val, flags=re.IGNORECASE)
|
|
||||||
return val
|
|
||||||
return node
|
|
||||||
|
|
||||||
|
|
||||||
def get_last_run_state(run_state_path: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
|
||||||
"""Return the last saved workflow run state from workflow_run_state.json, if any."""
|
|
||||||
p = run_state_path or paths.get_workflow_run_state_path()
|
|
||||||
if not p.is_file():
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
rel = resolved.relative_to(home).as_posix()
|
||||||
if isinstance(data, dict):
|
except ValueError as exc:
|
||||||
return sanitize_run_data(data)
|
raise ValueError("Agent File должен находиться внутри каталога HERMES_HOME") from exc
|
||||||
except Exception:
|
return resolved, rel
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]:
|
|
||||||
"""Resolve an Agent File below HERMES_HOME/agents and reject traversal."""
|
|
||||||
root = paths.get_agent_files_dir().resolve()
|
|
||||||
candidate_name = Path(value or f"{agent_id}.md").name
|
|
||||||
if not candidate_name.lower().endswith(".md"):
|
|
||||||
candidate_name += ".md"
|
|
||||||
target = (root / candidate_name).resolve()
|
|
||||||
if target.parent != root:
|
|
||||||
raise ValueError("Agent File должен находиться в каталоге agents")
|
|
||||||
return target, f"agents/{candidate_name}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -112,6 +127,7 @@ class WorkflowEdge:
|
||||||
target: str
|
target: str
|
||||||
condition: str = "SUCCESS"
|
condition: str = "SUCCESS"
|
||||||
label: str = ""
|
label: str = ""
|
||||||
|
max_iterations: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -124,6 +140,62 @@ class WorkflowDefinition:
|
||||||
start_agent_id: Optional[str] = None
|
start_agent_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_canonical_a36_pipeline() -> WorkflowDefinition:
|
||||||
|
"""Return canonical Antigravity 4-agent workflow with nested feedback loops."""
|
||||||
|
return WorkflowDefinition(
|
||||||
|
id="a36-pipeline",
|
||||||
|
name="Конвейер Antigravity (Оркестратор, 2 кодера, ревьюер)",
|
||||||
|
start_agent_id="manager",
|
||||||
|
max_iterations=5,
|
||||||
|
edges=[
|
||||||
|
WorkflowEdge(
|
||||||
|
id="edge-manager-to-dev1",
|
||||||
|
source="manager",
|
||||||
|
target="developer-1",
|
||||||
|
condition="SUCCESS",
|
||||||
|
label="Постановка задачи",
|
||||||
|
),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="edge-dev1-to-dev2",
|
||||||
|
source="developer-1",
|
||||||
|
target="developer-2",
|
||||||
|
condition="SUCCESS",
|
||||||
|
label="Реализация на проверку",
|
||||||
|
),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="edge-dev2-to-dev1",
|
||||||
|
source="developer-2",
|
||||||
|
target="developer-1",
|
||||||
|
condition="REVIEW_FAILED",
|
||||||
|
label="Доработка Кодеру 1",
|
||||||
|
max_iterations=5,
|
||||||
|
),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="edge-dev2-to-reviewer",
|
||||||
|
source="developer-2",
|
||||||
|
target="code-reviewer",
|
||||||
|
condition="REVIEW_PASSED",
|
||||||
|
label="Одобрено Кодером 2",
|
||||||
|
),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="edge-reviewer-to-dev2",
|
||||||
|
source="code-reviewer",
|
||||||
|
target="developer-2",
|
||||||
|
condition="REVIEW_FAILED",
|
||||||
|
label="Переделка Кодеру 2",
|
||||||
|
max_iterations=5,
|
||||||
|
),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="edge-reviewer-to-manager",
|
||||||
|
source="code-reviewer",
|
||||||
|
target="manager",
|
||||||
|
condition="REVIEW_PASSED",
|
||||||
|
label="Приёмка",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorkflowEvent:
|
class WorkflowEvent:
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
@ -206,12 +278,6 @@ class WorkflowService:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
# Идентификаторы агентов повторяют идентификаторы ролей, а роли со
|
|
||||||
# старыми именами переименовываются в канонические при загрузке
|
|
||||||
# конфигурации. Без такого же переименования здесь сохранённый
|
|
||||||
# workflow ссылался бы на исчезнувших агентов, и граф падал бы с
|
|
||||||
# «Ребро ссылается на отсутствующего агента».
|
|
||||||
from antigravity_provider.router.role_registry import RoleRegistry
|
from antigravity_provider.router.role_registry import RoleRegistry
|
||||||
|
|
||||||
def _canon(agent_id: str) -> str:
|
def _canon(agent_id: str) -> str:
|
||||||
|
|
@ -228,12 +294,18 @@ class WorkflowService:
|
||||||
item["id"] = _canon(item["id"])
|
item["id"] = _canon(item["id"])
|
||||||
if item.get("role"):
|
if item.get("role"):
|
||||||
item["role"] = _canon(item["role"])
|
item["role"] = _canon(item["role"])
|
||||||
# Первым выигрывает агент под старым именем: именно им
|
|
||||||
# пользовался владелец, канонический мог быть дописан пустым.
|
|
||||||
self.agents.setdefault(item["id"], AgentDefinition(**item))
|
self.agents.setdefault(item["id"], AgentDefinition(**item))
|
||||||
|
|
||||||
wf = raw.get("workflow") or {}
|
wf = raw.get("workflow") or {}
|
||||||
edges = []
|
edges = []
|
||||||
|
valid_edge_keys = {
|
||||||
|
"id",
|
||||||
|
"source",
|
||||||
|
"target",
|
||||||
|
"condition",
|
||||||
|
"label",
|
||||||
|
"max_iterations",
|
||||||
|
}
|
||||||
for edge in wf.pop("edges", []):
|
for edge in wf.pop("edges", []):
|
||||||
if not isinstance(edge, dict):
|
if not isinstance(edge, dict):
|
||||||
continue
|
continue
|
||||||
|
|
@ -241,7 +313,8 @@ class WorkflowService:
|
||||||
for key in ("source", "target", "from_agent", "to_agent"):
|
for key in ("source", "target", "from_agent", "to_agent"):
|
||||||
if edge.get(key):
|
if edge.get(key):
|
||||||
edge[key] = _canon(edge[key])
|
edge[key] = _canon(edge[key])
|
||||||
edges.append(WorkflowEdge(**edge))
|
filtered_edge = {k: v for k, v in edge.items() if k in valid_edge_keys}
|
||||||
|
edges.append(WorkflowEdge(**filtered_edge))
|
||||||
self.workflow = WorkflowDefinition(edges=edges, **wf)
|
self.workflow = WorkflowDefinition(edges=edges, **wf)
|
||||||
self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]]
|
self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]]
|
||||||
self.run = raw.get("run") or self._idle_run()
|
self.run = raw.get("run") or self._idle_run()
|
||||||
|
|
@ -317,8 +390,8 @@ class WorkflowService:
|
||||||
from antigravity_provider.router.role_registry import get_role_definition
|
from antigravity_provider.router.role_registry import get_role_definition
|
||||||
|
|
||||||
definition = get_role_definition(role_id)
|
definition = get_role_definition(role_id)
|
||||||
name = getattr(definition, "name", None) or getattr(definition, "display_name", None) or name
|
name = getattr(definition, "display_name_ru", None) or getattr(definition, "name", None) or name
|
||||||
description = getattr(definition, "description", "")
|
description = getattr(definition, "description_ru", "") or getattr(definition, "description", "")
|
||||||
except (ImportError, AttributeError, TypeError):
|
except (ImportError, AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
agent = AgentDefinition(
|
agent = AgentDefinition(
|
||||||
|
|
@ -332,12 +405,28 @@ class WorkflowService:
|
||||||
self.agents[role_id] = agent
|
self.agents[role_id] = agent
|
||||||
self._ensure_file(target, agent)
|
self._ensure_file(target, agent)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
if not self.workflow.edges:
|
||||||
|
a36_roles = {"manager", "developer-1", "developer-2", "code-reviewer"}
|
||||||
|
if a36_roles.issubset(self.agents.keys()):
|
||||||
|
self.workflow = get_canonical_a36_pipeline()
|
||||||
|
if "manager" in self.agents:
|
||||||
|
self.agents["manager"].position = {"x": 60.0, "y": 140.0}
|
||||||
|
if "developer-1" in self.agents:
|
||||||
|
self.agents["developer-1"].position = {"x": 320.0, "y": 140.0}
|
||||||
|
if "developer-2" in self.agents:
|
||||||
|
self.agents["developer-2"].position = {"x": 580.0, "y": 140.0}
|
||||||
|
if "code-reviewer" in self.agents:
|
||||||
|
self.agents["code-reviewer"].position = {"x": 840.0, "y": 140.0}
|
||||||
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_file(target: Path, agent: AgentDefinition) -> None:
|
def _ensure_file(target: Path, agent: AgentDefinition) -> None:
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n"
|
body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n"
|
||||||
target.write_text(body, encoding="utf-8")
|
target.write_text(body, encoding="utf-8")
|
||||||
|
|
||||||
|
|
@ -390,6 +479,7 @@ class WorkflowService:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
agent = self._require_agent(agent_id)
|
agent = self._require_agent(agent_id)
|
||||||
target, relative = _safe_agent_file(agent.agent_file, agent.id)
|
target, relative = _safe_agent_file(agent.agent_file, agent.id)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temporary = target.with_suffix(".md.tmp")
|
temporary = target.with_suffix(".md.tmp")
|
||||||
temporary.write_text(str(content), encoding="utf-8")
|
temporary.write_text(str(content), encoding="utf-8")
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
|
|
@ -404,6 +494,8 @@ class WorkflowService:
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("Укажите название агента")
|
raise ValueError("Укажите название агента")
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if self.run.get("status") in {"running", "stopping"}:
|
||||||
|
raise ValueError("Нельзя менять конфигурацию агентов во время выполнения workflow")
|
||||||
if agent_id in self.agents:
|
if agent_id in self.agents:
|
||||||
raise ValueError("Агент с таким идентификатором уже существует")
|
raise ValueError("Агент с таким идентификатором уже существует")
|
||||||
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
|
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
|
||||||
|
|
@ -446,6 +538,8 @@ class WorkflowService:
|
||||||
|
|
||||||
def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition:
|
def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if self.run.get("status") in {"running", "stopping"}:
|
||||||
|
raise ValueError("Нельзя менять конфигурацию агентов во время выполнения workflow")
|
||||||
agent = self._require_agent(agent_id)
|
agent = self._require_agent(agent_id)
|
||||||
config = load_router_config()
|
config = load_router_config()
|
||||||
policy = config.roles.get(agent.role)
|
policy = config.roles.get(agent.role)
|
||||||
|
|
@ -463,10 +557,11 @@ class WorkflowService:
|
||||||
policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id]
|
policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id]
|
||||||
if "model" in data:
|
if "model" in data:
|
||||||
model = str(data.get("model") or "").strip() or None
|
model = str(data.get("model") or "").strip() or None
|
||||||
if model and profile_id:
|
if model and policy.preferred_chain:
|
||||||
profile = config.profiles[profile_id]
|
eff_profile_id = policy.preferred_chain[0]
|
||||||
if model not in profile.preferred_models:
|
eff_profile = config.profiles.get(eff_profile_id)
|
||||||
raise ValueError("Модель не доступна выбранному аккаунту")
|
if eff_profile and eff_profile.preferred_models and model not in eff_profile.preferred_models:
|
||||||
|
raise ValueError(f"Модель {model} не доступна для профиля {eff_profile_id}")
|
||||||
policy.default_model = model
|
policy.default_model = model
|
||||||
if not save_router_config(config):
|
if not save_router_config(config):
|
||||||
raise OSError("Не удалось сохранить назначение агента")
|
raise OSError("Не удалось сохранить назначение агента")
|
||||||
|
|
@ -487,6 +582,8 @@ class WorkflowService:
|
||||||
|
|
||||||
def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]:
|
def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if self.run.get("status") in {"running", "stopping"}:
|
||||||
|
raise ValueError("Нельзя удалять агентов во время выполнения workflow")
|
||||||
agent = self._require_agent(agent_id)
|
agent = self._require_agent(agent_id)
|
||||||
edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id]
|
edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id]
|
||||||
route_used = bool(load_router_config().roles.get(agent.role))
|
route_used = bool(load_router_config().roles.get(agent.role))
|
||||||
|
|
@ -498,7 +595,17 @@ class WorkflowService:
|
||||||
if not save_router_config(config):
|
if not save_router_config(config):
|
||||||
raise OSError("Не удалось удалить роль из маршрутизатора")
|
raise OSError("Не удалось удалить роль из маршрутизатора")
|
||||||
self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids]
|
self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids]
|
||||||
self.agents.pop(agent_id)
|
if self.workflow.start_agent_id == agent_id:
|
||||||
|
self.workflow.start_agent_id = None
|
||||||
|
if self.workflow.escalation_agent_id == agent_id:
|
||||||
|
self.workflow.escalation_agent_id = None
|
||||||
|
target, _ = _safe_agent_file(agent.agent_file, agent.id)
|
||||||
|
if target.exists():
|
||||||
|
try:
|
||||||
|
target.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.agents.pop(agent_id, None)
|
||||||
self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning")
|
self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning")
|
||||||
self._save()
|
self._save()
|
||||||
return {"deleted": True, "consequences": consequences}
|
return {"deleted": True, "consequences": consequences}
|
||||||
|
|
@ -509,18 +616,35 @@ class WorkflowService:
|
||||||
raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения")
|
raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения")
|
||||||
edges: list[WorkflowEdge] = []
|
edges: list[WorkflowEdge] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for raw in data.get("edges", []):
|
for raw_edge in data.get("edges", []):
|
||||||
source, target = str(raw.get("source") or ""), str(raw.get("target") or "")
|
source, target = str(raw_edge.get("source") or ""), str(raw_edge.get("target") or "")
|
||||||
condition = str(raw.get("condition") or "SUCCESS").upper()
|
condition = str(raw_edge.get("condition") or "SUCCESS").upper()
|
||||||
if source not in self.agents or target not in self.agents:
|
if source not in self.agents or target not in self.agents:
|
||||||
raise ValueError("Ребро ссылается на отсутствующего агента")
|
raise ValueError("Ребро ссылается на отсутствующего агента")
|
||||||
if condition not in EDGE_CONDITIONS:
|
if condition not in EDGE_CONDITIONS:
|
||||||
raise ValueError(f"Неизвестное условие перехода: {condition}")
|
raise ValueError(f"Неизвестное условие перехода: {condition}")
|
||||||
edge_id = str(raw.get("id") or f"edge-{uuid.uuid4().hex[:10]}")
|
edge_id = str(raw_edge.get("id") or f"edge-{uuid.uuid4().hex[:10]}")
|
||||||
if edge_id in seen:
|
if edge_id in seen:
|
||||||
raise ValueError("Идентификаторы рёбер должны быть уникальны")
|
raise ValueError("Идентификаторы рёбер должны быть уникальны")
|
||||||
seen.add(edge_id)
|
seen.add(edge_id)
|
||||||
edges.append(WorkflowEdge(edge_id, source, target, condition, str(raw.get("label") or "")))
|
edge_max_it = raw_edge.get("max_iterations")
|
||||||
|
if edge_max_it is not None:
|
||||||
|
try:
|
||||||
|
edge_max_it = int(edge_max_it)
|
||||||
|
if not 1 <= edge_max_it <= 100:
|
||||||
|
edge_max_it = None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
edge_max_it = None
|
||||||
|
edges.append(
|
||||||
|
WorkflowEdge(
|
||||||
|
id=edge_id,
|
||||||
|
source=source,
|
||||||
|
target=target,
|
||||||
|
condition=condition,
|
||||||
|
label=str(raw_edge.get("label") or ""),
|
||||||
|
max_iterations=edge_max_it,
|
||||||
|
)
|
||||||
|
)
|
||||||
max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations)
|
max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations)
|
||||||
if not 1 <= max_iterations <= 100:
|
if not 1 <= max_iterations <= 100:
|
||||||
raise ValueError("Предел итераций должен быть от 1 до 100")
|
raise ValueError("Предел итераций должен быть от 1 до 100")
|
||||||
|
|
@ -577,7 +701,13 @@ class WorkflowService:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning")
|
self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning")
|
||||||
self._save()
|
self._save()
|
||||||
self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=self.run.get("current_agent_id"), iteration=self.run.get("iteration", 1), interruption_reason="Остановлено пользователем")
|
self._save_run_state(
|
||||||
|
"STOPPED",
|
||||||
|
step_index=len(self._completed_steps),
|
||||||
|
current_agent=self.run.get("current_agent_id"),
|
||||||
|
iteration=self.run.get("iteration", 1),
|
||||||
|
interruption_reason="Остановлено пользователем",
|
||||||
|
)
|
||||||
return dict(self.run)
|
return dict(self.run)
|
||||||
|
|
||||||
def _execute(self) -> None:
|
def _execute(self) -> None:
|
||||||
|
|
@ -587,6 +717,7 @@ class WorkflowService:
|
||||||
context = str(self.run.get("current_task") or "")
|
context = str(self.run.get("current_task") or "")
|
||||||
current = str(self.run.get("current_agent_id") or "")
|
current = str(self.run.get("current_agent_id") or "")
|
||||||
visited: dict[str, int] = {}
|
visited: dict[str, int] = {}
|
||||||
|
edge_counts: dict[str, int] = {}
|
||||||
try:
|
try:
|
||||||
engine = get_router_engine()
|
engine = get_router_engine()
|
||||||
engine.reload_config()
|
engine.reload_config()
|
||||||
|
|
@ -594,8 +725,14 @@ class WorkflowService:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
agent = self._require_agent(current)
|
agent = self._require_agent(current)
|
||||||
visited[current] = visited.get(current, 0) + 1
|
visited[current] = visited.get(current, 0) + 1
|
||||||
iteration = max(visited.values())
|
iteration = visited[current]
|
||||||
self.run.update({"current_agent_id": current, "iteration": iteration})
|
global_iteration = sum(visited.values())
|
||||||
|
self.run.update({
|
||||||
|
"current_agent_id": current,
|
||||||
|
"iteration": iteration,
|
||||||
|
"global_iteration": global_iteration,
|
||||||
|
"max_iterations": self.workflow.max_iterations,
|
||||||
|
})
|
||||||
step_idx = len(self._completed_steps)
|
step_idx = len(self._completed_steps)
|
||||||
if iteration > self.workflow.max_iterations:
|
if iteration > self.workflow.max_iterations:
|
||||||
message = f"Достигнут предел итераций: {self.workflow.max_iterations}"
|
message = f"Достигнут предел итераций: {self.workflow.max_iterations}"
|
||||||
|
|
@ -605,7 +742,7 @@ class WorkflowService:
|
||||||
break
|
break
|
||||||
file_data = self.read_agent_file(current)
|
file_data = self.read_agent_file(current)
|
||||||
if not file_data["exists"]:
|
if not file_data["exists"]:
|
||||||
raise FileNotFoundError(f"{file_data['path']}: {file_data['reason']}")
|
raise FileNotFoundError(f"{file_data['path']}: {file_data.get('reason')}")
|
||||||
self.run.setdefault("agent_states", {})[current] = (
|
self.run.setdefault("agent_states", {})[current] = (
|
||||||
"reviewing" if "review" in agent.role.lower() else "working"
|
"reviewing" if "review" in agent.role.lower() else "working"
|
||||||
)
|
)
|
||||||
|
|
@ -665,7 +802,7 @@ class WorkflowService:
|
||||||
(item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}),
|
(item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if not edge and status not in {"ERROR", "REVIEW_FAILED"}:
|
if not edge and status in {"SUCCESS", "NEXT"}:
|
||||||
edge = next(
|
edge = next(
|
||||||
(item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}),
|
(item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}),
|
||||||
None,
|
None,
|
||||||
|
|
@ -703,6 +840,16 @@ class WorkflowService:
|
||||||
interruption_reason=self.run.get("error") if self.run["status"] == "failed" else None,
|
interruption_reason=self.run.get("error") if self.run["status"] == "failed" else None,
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
edge_counts[edge.id] = edge_counts.get(edge.id, 0) + 1
|
||||||
|
edge_limit = edge.max_iterations if edge.max_iterations is not None else self.workflow.max_iterations
|
||||||
|
if edge_counts[edge.id] > edge_limit:
|
||||||
|
message = f"Достигнут предел итераций для перехода {edge.source} → {edge.target}: {edge_limit}"
|
||||||
|
self.run.update({"status": "failed", "error": message})
|
||||||
|
self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration)
|
||||||
|
self._save_run_state("FAILED", step_index=step_idx + 1, current_agent=current, iteration=iteration, interruption_reason=message)
|
||||||
|
break
|
||||||
|
|
||||||
self._event(
|
self._event(
|
||||||
"WORKFLOW_TRANSITION",
|
"WORKFLOW_TRANSITION",
|
||||||
f"Переход {current} → {edge.target}: {edge.condition}",
|
f"Переход {current} → {edge.target}: {edge.condition}",
|
||||||
|
|
@ -746,7 +893,6 @@ class WorkflowService:
|
||||||
self.run["current_agent_id"] = current or self.run.get("current_agent_id")
|
self.run["current_agent_id"] = current or self.run.get("current_agent_id")
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _response_text(response: Any) -> str:
|
def _response_text(response: Any) -> str:
|
||||||
if not isinstance(response, dict):
|
if not isinstance(response, dict):
|
||||||
|
|
@ -763,9 +909,26 @@ class WorkflowService:
|
||||||
explicit = response.get("status") or response.get("structured_status")
|
explicit = response.get("status") or response.get("structured_status")
|
||||||
if explicit and str(explicit).upper() in EDGE_CONDITIONS:
|
if explicit and str(explicit).upper() in EDGE_CONDITIONS:
|
||||||
return str(explicit).upper()
|
return str(explicit).upper()
|
||||||
for status in ("REVIEW_FAILED", "REVIEW_PASSED", "SUCCESS", "ERROR"):
|
|
||||||
if re.search(rf"\b{status}\b", text.upper()):
|
text_upper = text.upper()
|
||||||
|
# 1. Match leading status keyword or STATUS: <keyword>
|
||||||
|
prefix_match = re.search(
|
||||||
|
r"^\s*(?:STATUS\s*:\s*|\[STATUS\s*:\s*)?(REVIEW_FAILED|REVIEW_PASSED|COMPLETED|ACCEPTED|SUCCESS|ERROR)\b",
|
||||||
|
text_upper,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
if prefix_match:
|
||||||
|
return prefix_match.group(1)
|
||||||
|
|
||||||
|
# 2. Match multi-word distinct statuses anywhere
|
||||||
|
for status in ("REVIEW_FAILED", "REVIEW_PASSED", "COMPLETED", "ACCEPTED"):
|
||||||
|
if re.search(rf"\b{status}\b", text_upper):
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
# 3. Explicit error status markers vs regular discussion of errors
|
||||||
|
if re.search(r"\bSTATUS\s*:\s*ERROR\b", text_upper) or re.search(r"\b\[ERROR\]\b", text_upper):
|
||||||
|
return "ERROR"
|
||||||
|
|
||||||
return "SUCCESS"
|
return "SUCCESS"
|
||||||
|
|
||||||
def _event(self, event_type: str, message: str, **kwargs: Any) -> None:
|
def _event(self, event_type: str, message: str, **kwargs: Any) -> None:
|
||||||
|
|
|
||||||
271
tests/test_a35_role_resolution.py
Normal file
271
tests/test_a35_role_resolution.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from antigravity_provider.router.router_config import (
|
||||||
|
RolePolicy,
|
||||||
|
RouterConfig,
|
||||||
|
RouterProfileConfig,
|
||||||
|
save_router_config,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
||||||
|
from antigravity_provider.hermes_plugin import antigravity_llm_execution
|
||||||
|
from antigravity_provider.router.settings_service import save_hub_settings, invalidate_settings_cache
|
||||||
|
|
||||||
|
|
||||||
|
class TestA35RoleResolution(unittest.TestCase):
|
||||||
|
"""P0-1 & P0-2: Role resolution and failover safety for Hermes Hub integration."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp_dir = tempfile.mkdtemp(prefix="hermes_test_a35_")
|
||||||
|
self.config_path = Path(self.tmp_dir) / "router_profiles.yaml"
|
||||||
|
self.settings_path = Path(self.tmp_dir) / "hub_settings.json"
|
||||||
|
|
||||||
|
self.profiles = {
|
||||||
|
"ag-orch-fallback": RouterProfileConfig(
|
||||||
|
profile_id="ag-orch-fallback",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["gemini-3.7-flash"],
|
||||||
|
),
|
||||||
|
"ag-w1": RouterProfileConfig(
|
||||||
|
profile_id="ag-w1",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["gemini-3.7-flash"],
|
||||||
|
),
|
||||||
|
"ag-w2": RouterProfileConfig(
|
||||||
|
profile_id="ag-w2",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["gemini-3.1-pro-high"],
|
||||||
|
),
|
||||||
|
"ag-w3": RouterProfileConfig(
|
||||||
|
profile_id="ag-w3",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["claude-opus-4-6-thinking"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.roles = {
|
||||||
|
"manager": RolePolicy(
|
||||||
|
role_name="manager",
|
||||||
|
preferred_chain=["ag-orch-fallback"],
|
||||||
|
default_model="gemini-3.7-flash",
|
||||||
|
),
|
||||||
|
"developer-1": RolePolicy(
|
||||||
|
role_name="developer-1",
|
||||||
|
preferred_chain=["ag-w1"],
|
||||||
|
default_model="gemini-3.7-flash",
|
||||||
|
),
|
||||||
|
"developer-2": RolePolicy(
|
||||||
|
role_name="developer-2",
|
||||||
|
preferred_chain=["ag-w2"],
|
||||||
|
default_model="gemini-3.1-pro-high",
|
||||||
|
),
|
||||||
|
"code-reviewer": RolePolicy(
|
||||||
|
role_name="code-reviewer",
|
||||||
|
preferred_chain=["ag-w3"],
|
||||||
|
default_model="claude-opus-4-6-thinking",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.config = RouterConfig(
|
||||||
|
enabled=True,
|
||||||
|
default_role="manager",
|
||||||
|
roles=self.roles,
|
||||||
|
profiles=self.profiles,
|
||||||
|
)
|
||||||
|
save_router_config(self.config, self.config_path)
|
||||||
|
|
||||||
|
self.env_patcher = patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"HERMES_HOME": self.tmp_dir,
|
||||||
|
"HERMES_ROUTER_PROFILES": str(self.config_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.env_patcher.start()
|
||||||
|
invalidate_settings_cache()
|
||||||
|
self.engine = RouterEngine(self.config)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.env_patcher.stop()
|
||||||
|
invalidate_settings_cache()
|
||||||
|
|
||||||
|
def test_explicit_role_resolution_level1(self):
|
||||||
|
"""Level 1: Explicit role in arguments, request or metadata takes highest priority."""
|
||||||
|
# 1. Via explicit_role arg
|
||||||
|
role, source = self.engine.resolve_role_with_source({}, explicit_role="developer-2")
|
||||||
|
self.assertEqual(role, "developer-2")
|
||||||
|
self.assertEqual(source, "explicit")
|
||||||
|
|
||||||
|
# 2. Via request['role']
|
||||||
|
role, source = self.engine.resolve_role_with_source({"role": "code-reviewer"})
|
||||||
|
self.assertEqual(role, "code-reviewer")
|
||||||
|
self.assertEqual(source, "explicit")
|
||||||
|
|
||||||
|
# 3. Via request['metadata']['role']
|
||||||
|
role, source = self.engine.resolve_role_with_source({"metadata": {"role": "manager"}})
|
||||||
|
self.assertEqual(role, "manager")
|
||||||
|
self.assertEqual(source, "explicit")
|
||||||
|
|
||||||
|
def test_model_and_provider_resolution_level2(self):
|
||||||
|
"""Level 2: Resolution by model and provider dynamically configured in router roles."""
|
||||||
|
# gemini-3.1-pro-high -> configured default_model for developer-2
|
||||||
|
role, source = self.engine.resolve_role_with_source({}, model="gemini-3.1-pro-high")
|
||||||
|
self.assertEqual(role, "developer-2")
|
||||||
|
self.assertEqual(source, "model_match")
|
||||||
|
|
||||||
|
# claude-opus-4-6-thinking -> configured default_model for code-reviewer
|
||||||
|
role, source = self.engine.resolve_role_with_source({}, model="claude-opus-4-6-thinking")
|
||||||
|
self.assertEqual(role, "code-reviewer")
|
||||||
|
self.assertEqual(source, "model_match")
|
||||||
|
|
||||||
|
# gemini-3.7-flash -> configured default_model for manager / developer-1
|
||||||
|
role, source = self.engine.resolve_role_with_source({}, model="gemini-3.7-flash")
|
||||||
|
self.assertIn(role, ["manager", "developer-1"])
|
||||||
|
self.assertEqual(source, "model_match")
|
||||||
|
|
||||||
|
def test_session_affinity_resolution_level3(self):
|
||||||
|
"""Level 3: Resolution by session affinity for session_id."""
|
||||||
|
sess_id = "sess-affinity-test-123"
|
||||||
|
# Register affinity record in router engine
|
||||||
|
self.engine.affinity.set_affinity(sess_id, role="developer-2", profile_id="ag-w2")
|
||||||
|
|
||||||
|
role, source = self.engine.resolve_role_with_source({}, session_id=sess_id)
|
||||||
|
self.assertEqual(role, "developer-2")
|
||||||
|
self.assertEqual(source, "session_affinity")
|
||||||
|
|
||||||
|
def test_default_fallback_role_level4(self):
|
||||||
|
"""Level 4: Default fallback role when no explicit, model, or session affinity matched."""
|
||||||
|
# By default, default_fallback gives 'manager'
|
||||||
|
role, source = self.engine.resolve_role_with_source(
|
||||||
|
{"messages": [{"role": "user", "content": "hello"}]},
|
||||||
|
fallback_to_default=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(role, "manager")
|
||||||
|
self.assertEqual(source, "default_fallback")
|
||||||
|
|
||||||
|
# Configurable via hub_settings.json
|
||||||
|
save_hub_settings({"default_role": "code-reviewer"})
|
||||||
|
role, source = self.engine.resolve_role_with_source(
|
||||||
|
{"messages": [{"role": "user", "content": "hello"}]},
|
||||||
|
fallback_to_default=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(role, "code-reviewer")
|
||||||
|
self.assertEqual(source, "default_fallback")
|
||||||
|
|
||||||
|
def test_no_prompt_guessing_returns_none_when_fallback_disabled(self):
|
||||||
|
"""Zero prompt guessing: unspecified role returns None when fallback_to_default=False."""
|
||||||
|
req = {"messages": [{"role": "system", "content": "You are a senior coding agent developer"}]}
|
||||||
|
role, source = self.engine.resolve_role_with_source(req, fallback_to_default=False)
|
||||||
|
self.assertIsNone(role)
|
||||||
|
self.assertEqual(source, "none")
|
||||||
|
|
||||||
|
def test_router_error_never_returned_as_assistant_content_to_hermes(self):
|
||||||
|
"""Safety Fuse: Router failover exhaustion (router_error) passes call downstream to next_call."""
|
||||||
|
downstream_calls = []
|
||||||
|
|
||||||
|
def mock_next(req):
|
||||||
|
downstream_calls.append(req)
|
||||||
|
return {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {"role": "assistant", "content": "clean-downstream-response"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock engine route_request to return failover exhaustion error payload
|
||||||
|
exhausted_payload = {
|
||||||
|
"router_error": True,
|
||||||
|
"error_type": "exhausted",
|
||||||
|
"message": "All 3 profiles in role 'manager' failed",
|
||||||
|
"failover_trail": ["ag-orch-fallback: timeout"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.get_router_engine", return_value=self.engine):
|
||||||
|
with patch.object(self.engine, "route_request", return_value=exhausted_payload):
|
||||||
|
res = antigravity_llm_execution(
|
||||||
|
request={"messages": [{"role": "user", "content": "test safety"}]},
|
||||||
|
next_call=mock_next,
|
||||||
|
provider="antigravity",
|
||||||
|
model="gemini-3.7-flash",
|
||||||
|
session_id="sess-safety-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verified: Downstream call was executed and router error text did NOT become the assistant message
|
||||||
|
self.assertEqual(len(downstream_calls), 1)
|
||||||
|
content = res["choices"][0]["message"]["content"]
|
||||||
|
self.assertEqual(content, "clean-downstream-response")
|
||||||
|
self.assertNotIn("exhausted", content.lower())
|
||||||
|
self.assertNotIn("router_error", content.lower())
|
||||||
|
|
||||||
|
def test_empty_profile_config_passes_cleanly_to_next_call(self):
|
||||||
|
"""Empty profile configuration must not break Hermes and falls through cleanly."""
|
||||||
|
empty_config = RouterConfig(enabled=True, roles={}, profiles={})
|
||||||
|
empty_engine = RouterEngine(empty_config)
|
||||||
|
|
||||||
|
downstream_calls = []
|
||||||
|
|
||||||
|
def mock_next(req):
|
||||||
|
downstream_calls.append(req)
|
||||||
|
return {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {"role": "assistant", "content": "clean-passthrough-response"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.get_router_engine", return_value=empty_engine):
|
||||||
|
res = antigravity_llm_execution(
|
||||||
|
request={"messages": [{"role": "user", "content": "test passthrough"}]},
|
||||||
|
next_call=mock_next,
|
||||||
|
provider="antigravity",
|
||||||
|
model="gemini-3.7-flash",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(downstream_calls), 1)
|
||||||
|
self.assertEqual(res["choices"][0]["message"]["content"], "clean-passthrough-response")
|
||||||
|
|
||||||
|
def test_hermes_call_records_event_log_and_telemetry(self):
|
||||||
|
"""Successful Hermes routing records chosen role, reason, profile, and telemetry."""
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
|
||||||
|
mock_completion = {
|
||||||
|
"choices": [{"message": {"role": "assistant", "content": "model-output-ok"}}],
|
||||||
|
"router_metadata": {
|
||||||
|
"provider": "antigravity",
|
||||||
|
"profile_id": "ag-w2",
|
||||||
|
"selected_model": "gemini-3.1-pro-high",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.get_router_engine", return_value=self.engine):
|
||||||
|
with patch.object(self.engine, "route_request", return_value=mock_completion):
|
||||||
|
res = antigravity_llm_execution(
|
||||||
|
request={"messages": [{"role": "user", "content": "test event logging"}]},
|
||||||
|
provider="antigravity",
|
||||||
|
model="gemini-3.1-pro-high",
|
||||||
|
session_id="sess-log-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = (
|
||||||
|
res.choices[0].message.content
|
||||||
|
if hasattr(res, "choices")
|
||||||
|
else res["choices"][0]["message"]["content"]
|
||||||
|
)
|
||||||
|
self.assertEqual(content, "model-output-ok")
|
||||||
|
events = EventLogService.get().get_events(limit=10, category="routing")
|
||||||
|
self.assertTrue(any("developer-2" in e.message for e in events))
|
||||||
|
matching_event = next(e for e in events if "developer-2" in e.message)
|
||||||
|
self.assertIn("по модели и провайдеру", matching_event.message)
|
||||||
|
self.assertIn("ag-w2", matching_event.details)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
308
tests/test_a36_pipeline.py
Normal file
308
tests/test_a36_pipeline.py
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from antigravity_provider.router.router_config import (
|
||||||
|
RolePolicy,
|
||||||
|
RouterConfig,
|
||||||
|
RouterProfileConfig,
|
||||||
|
save_router_config,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.workflow_service import (
|
||||||
|
WorkflowService,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowEdge,
|
||||||
|
get_canonical_a36_pipeline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestA36AntigravityPipeline(unittest.TestCase):
|
||||||
|
"""P0-1 .. P0-5: Antigravity pipeline graph, loops, limits, models and execution."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp_dir = tempfile.mkdtemp(prefix="hermes_test_a36_")
|
||||||
|
self.config_path = Path(self.tmp_dir) / "router_profiles.yaml"
|
||||||
|
self.state_path = Path(self.tmp_dir) / "workflow_state.json"
|
||||||
|
|
||||||
|
self.profiles = {
|
||||||
|
"ag-orch-fallback": RouterProfileConfig(
|
||||||
|
profile_id="ag-orch-fallback",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["gemini-3.7-flash"],
|
||||||
|
),
|
||||||
|
"ag-w1": RouterProfileConfig(
|
||||||
|
profile_id="ag-w1",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["gemini-3.7-flash", "gemini-3.7-flash-high"],
|
||||||
|
),
|
||||||
|
"ag-w2": RouterProfileConfig(
|
||||||
|
profile_id="ag-w2",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["gemini-3.1-pro-high", "gemini-3.1-pro-low"],
|
||||||
|
),
|
||||||
|
"ag-w3": RouterProfileConfig(
|
||||||
|
profile_id="ag-w3",
|
||||||
|
provider="antigravity",
|
||||||
|
preferred_models=["claude-opus-4-6-thinking"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.roles = {
|
||||||
|
"manager": RolePolicy(
|
||||||
|
role_name="manager",
|
||||||
|
preferred_chain=["ag-orch-fallback"],
|
||||||
|
default_model="gemini-3.7-flash",
|
||||||
|
),
|
||||||
|
"developer-1": RolePolicy(
|
||||||
|
role_name="developer-1",
|
||||||
|
preferred_chain=["ag-w1"],
|
||||||
|
default_model="gemini-3.7-flash",
|
||||||
|
),
|
||||||
|
"developer-2": RolePolicy(
|
||||||
|
role_name="developer-2",
|
||||||
|
preferred_chain=["ag-w2"],
|
||||||
|
default_model="gemini-3.1-pro-high",
|
||||||
|
),
|
||||||
|
"code-reviewer": RolePolicy(
|
||||||
|
role_name="code-reviewer",
|
||||||
|
preferred_chain=["ag-w3"],
|
||||||
|
default_model="claude-opus-4-6-thinking",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.config = RouterConfig(
|
||||||
|
enabled=True,
|
||||||
|
default_role="manager",
|
||||||
|
roles=self.roles,
|
||||||
|
profiles=self.profiles,
|
||||||
|
)
|
||||||
|
save_router_config(self.config, self.config_path)
|
||||||
|
|
||||||
|
self.env_patcher = patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"HERMES_HOME": self.tmp_dir,
|
||||||
|
"HERMES_ROUTER_PROFILES": str(self.config_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.env_patcher.start()
|
||||||
|
self.wf_service = WorkflowService(self.state_path)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.env_patcher.stop()
|
||||||
|
|
||||||
|
def test_canonical_pipeline_graph_structure(self):
|
||||||
|
"""P0-1: Canonical graph matches orchestrator, two coders, reviewer layout."""
|
||||||
|
snapshot = self.wf_service.snapshot()
|
||||||
|
agents = {a["id"]: a for a in snapshot["agents"]}
|
||||||
|
|
||||||
|
# 1. Check all 4 agents exist
|
||||||
|
self.assertIn("manager", agents)
|
||||||
|
self.assertIn("developer-1", agents)
|
||||||
|
self.assertIn("developer-2", agents)
|
||||||
|
self.assertIn("code-reviewer", agents)
|
||||||
|
|
||||||
|
# 2. Check model bindings
|
||||||
|
self.assertEqual(agents["manager"]["execution_config"]["model"], "gemini-3.7-flash")
|
||||||
|
self.assertEqual(agents["developer-1"]["execution_config"]["model"], "gemini-3.7-flash")
|
||||||
|
self.assertEqual(agents["developer-2"]["execution_config"]["model"], "gemini-3.1-pro-high")
|
||||||
|
self.assertEqual(agents["code-reviewer"]["execution_config"]["model"], "claude-opus-4-6-thinking")
|
||||||
|
|
||||||
|
# 3. Check account bindings
|
||||||
|
self.assertEqual(agents["manager"]["execution_config"]["account"], "ag-orch-fallback")
|
||||||
|
self.assertEqual(agents["developer-1"]["execution_config"]["account"], "ag-w1")
|
||||||
|
self.assertEqual(agents["developer-2"]["execution_config"]["account"], "ag-w2")
|
||||||
|
self.assertEqual(agents["code-reviewer"]["execution_config"]["account"], "ag-w3")
|
||||||
|
|
||||||
|
# 4. Check edges and feedback loops
|
||||||
|
definition = snapshot["definition"]
|
||||||
|
self.assertEqual(definition["start_agent_id"], "manager")
|
||||||
|
edges = {(e["source"], e["target"], e["condition"]) for e in definition["edges"]}
|
||||||
|
|
||||||
|
# Forward flow
|
||||||
|
self.assertIn(("manager", "developer-1", "SUCCESS"), edges)
|
||||||
|
self.assertIn(("developer-1", "developer-2", "SUCCESS"), edges)
|
||||||
|
self.assertIn(("developer-2", "code-reviewer", "REVIEW_PASSED"), edges)
|
||||||
|
self.assertIn(("code-reviewer", "manager", "REVIEW_PASSED"), edges)
|
||||||
|
|
||||||
|
# Inner feedback loop: developer-2 -> developer-1 on REVIEW_FAILED
|
||||||
|
self.assertIn(("developer-2", "developer-1", "REVIEW_FAILED"), edges)
|
||||||
|
|
||||||
|
# Outer feedback loop: code-reviewer -> developer-2 (NOT developer-1!) on REVIEW_FAILED
|
||||||
|
self.assertIn(("code-reviewer", "developer-2", "REVIEW_FAILED"), edges)
|
||||||
|
|
||||||
|
def test_live_execution_with_triggered_inner_loop(self):
|
||||||
|
"""P0-3: Live execution run with Coder 2 returning work to Coder 1."""
|
||||||
|
step_call_count = {"developer-1": 0, "developer-2": 0, "code-reviewer": 0, "manager": 0}
|
||||||
|
step_history = []
|
||||||
|
|
||||||
|
class FakePipelineEngine:
|
||||||
|
def reload_config(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def route_request(self, request, role=None, session_id=None):
|
||||||
|
step_call_count[role] = step_call_count.get(role, 0) + 1
|
||||||
|
step_history.append((role, step_call_count[role]))
|
||||||
|
|
||||||
|
if role == "manager":
|
||||||
|
if step_call_count["manager"] == 1:
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "SUCCESS: Task dispatched to Developer 1"}}],
|
||||||
|
"router_metadata": {"profile_id": "ag-orch-fallback", "selected_model": "gemini-3.7-flash"},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "ACCEPTED: Project verified and accepted by Orchestrator"}}],
|
||||||
|
"router_metadata": {"profile_id": "ag-orch-fallback", "selected_model": "gemini-3.7-flash"},
|
||||||
|
}
|
||||||
|
elif role == "developer-1":
|
||||||
|
if step_call_count["developer-1"] == 1:
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "SUCCESS: Initial code implementation"}}],
|
||||||
|
"router_metadata": {"profile_id": "ag-w1", "selected_model": "gemini-3.7-flash"},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "SUCCESS: Fixed error handling per Coder 2 feedback"}}],
|
||||||
|
"router_metadata": {"profile_id": "ag-w1", "selected_model": "gemini-3.7-flash"},
|
||||||
|
}
|
||||||
|
elif role == "developer-2":
|
||||||
|
if step_call_count["developer-2"] == 1:
|
||||||
|
# First check: Reject and return to developer-1
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "REVIEW_FAILED: Missing error handling and edge cases"}}],
|
||||||
|
"router_metadata": {"profile_id": "ag-w2", "selected_model": "gemini-3.1-pro-high"},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Second check: Approve and advance to reviewer
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "REVIEW_PASSED: Code approved by Coder 2"}}],
|
||||||
|
"router_metadata": {"profile_id": "ag-w2", "selected_model": "gemini-3.1-pro-high"},
|
||||||
|
}
|
||||||
|
elif role == "code-reviewer":
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": "REVIEW_PASSED: Security and architecture approved"}}],
|
||||||
|
"router_metadata": {"profile_id": "ag-w3", "selected_model": "claude-opus-4-6-thinking"},
|
||||||
|
}
|
||||||
|
return {"choices": [{"message": {"content": "SUCCESS"}}]}
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.router_engine.get_router_engine", return_value=FakePipelineEngine()):
|
||||||
|
self.wf_service.start("Создать модуль аутентификации с валидацией токенов")
|
||||||
|
thread = self.wf_service._thread
|
||||||
|
self.assertIsNotNone(thread)
|
||||||
|
thread.join(timeout=5)
|
||||||
|
self.assertFalse(thread.is_alive(), "Workflow thread hung during execution")
|
||||||
|
|
||||||
|
# Verify completed execution status
|
||||||
|
self.assertEqual(
|
||||||
|
self.wf_service.run["status"],
|
||||||
|
"completed",
|
||||||
|
f"Run failed with error: {self.wf_service.run.get('error')}, events: {[e.message for e in self.wf_service.events]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify transition sequence
|
||||||
|
expected_sequence = [
|
||||||
|
("manager", 1),
|
||||||
|
("developer-1", 1),
|
||||||
|
("developer-2", 1), # Returns REVIEW_FAILED -> triggers loop back to dev-1
|
||||||
|
("developer-1", 2), # dev-1 fixes code
|
||||||
|
("developer-2", 2), # dev-2 approves -> REVIEW_PASSED
|
||||||
|
("code-reviewer", 1), # reviewer approves -> REVIEW_PASSED
|
||||||
|
("manager", 2), # manager acceptance -> completed
|
||||||
|
]
|
||||||
|
self.assertEqual(step_history, expected_sequence)
|
||||||
|
|
||||||
|
# Verify transition events
|
||||||
|
transitions = [e.message for e in self.wf_service.events if e.type == "WORKFLOW_TRANSITION"]
|
||||||
|
self.assertIn("Переход developer-2 → developer-1: REVIEW_FAILED", transitions)
|
||||||
|
self.assertIn("Переход developer-2 → code-reviewer: REVIEW_PASSED", transitions)
|
||||||
|
self.assertIn("Переход code-reviewer → manager: REVIEW_PASSED", transitions)
|
||||||
|
|
||||||
|
def test_iteration_limit_cutoff_and_event(self):
|
||||||
|
"""P0-2: Loop iteration cutoff emits WORKFLOW_MAX_ITERATIONS and sets failed status."""
|
||||||
|
self.wf_service.workflow.max_iterations = 2
|
||||||
|
self.wf_service._save()
|
||||||
|
|
||||||
|
class InfiniteLoopEngine:
|
||||||
|
def reload_config(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def route_request(self, request, role=None, session_id=None):
|
||||||
|
if role == "manager":
|
||||||
|
return {"choices": [{"message": {"content": "SUCCESS: Start task"}}]}
|
||||||
|
elif role == "developer-1":
|
||||||
|
return {"choices": [{"message": {"content": "SUCCESS: Dev 1 draft"}}]}
|
||||||
|
elif role == "developer-2":
|
||||||
|
# Always reject to simulate unending loop
|
||||||
|
return {"choices": [{"message": {"content": "REVIEW_FAILED: Reject again"}}]}
|
||||||
|
return {"choices": [{"message": {"content": "SUCCESS"}}]}
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.router_engine.get_router_engine", return_value=InfiniteLoopEngine()):
|
||||||
|
self.wf_service.start("Тест предела итераций")
|
||||||
|
thread = self.wf_service._thread
|
||||||
|
self.assertIsNotNone(thread)
|
||||||
|
thread.join(timeout=5)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
|
||||||
|
self.assertEqual(self.wf_service.run["status"], "failed")
|
||||||
|
self.assertIn("Достигнут предел итераций", self.wf_service.run["error"])
|
||||||
|
self.assertTrue(any(e.type == "WORKFLOW_MAX_ITERATIONS" for e in self.wf_service.events))
|
||||||
|
|
||||||
|
def test_agent_model_reconfiguration(self):
|
||||||
|
"""P0-4: Changing model on agent updates configuration and router policy."""
|
||||||
|
# Update developer-1 model to gemini-3.7-flash-high
|
||||||
|
updated = self.wf_service.update_agent("developer-1", {"model": "gemini-3.7-flash-high"})
|
||||||
|
self.assertEqual(updated.id, "developer-1")
|
||||||
|
|
||||||
|
snap = self.wf_service.snapshot()
|
||||||
|
dev1 = next(a for a in snap["agents"] if a["id"] == "developer-1")
|
||||||
|
self.assertEqual(dev1["execution_config"]["model"], "gemini-3.7-flash-high")
|
||||||
|
|
||||||
|
def test_multi_account_parallelism_without_global_mutex(self):
|
||||||
|
"""P0-4: Requests to distinct Antigravity accounts execute concurrently without blocking."""
|
||||||
|
from antigravity_provider.router.router_engine import RouterEngine
|
||||||
|
|
||||||
|
engine = RouterEngine(self.config)
|
||||||
|
execution_times = {}
|
||||||
|
|
||||||
|
def slow_execution(profile_id, duration=0.2):
|
||||||
|
t0 = time.monotonic()
|
||||||
|
time.sleep(duration)
|
||||||
|
execution_times[profile_id] = round(time.monotonic() - t0, 3)
|
||||||
|
return {
|
||||||
|
"choices": [{"message": {"content": f"output from {profile_id}"}}],
|
||||||
|
"router_metadata": {"profile_id": profile_id, "provider": "antigravity"},
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_adapter = MagicMock()
|
||||||
|
mock_adapter.invoke.side_effect = lambda profile, req: slow_execution(profile.profile_id)
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.router_engine.get_adapter", return_value=mock_adapter):
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
start_t = time.monotonic()
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
|
f1 = executor.submit(engine.route_request, {"model": "gemini-3.7-flash"}, role="developer-1")
|
||||||
|
f2 = executor.submit(engine.route_request, {"model": "gemini-3.1-pro-high"}, role="developer-2")
|
||||||
|
f3 = executor.submit(engine.route_request, {"model": "claude-opus-4-6-thinking"}, role="code-reviewer")
|
||||||
|
|
||||||
|
r1 = f1.result(timeout=2)
|
||||||
|
r2 = f2.result(timeout=2)
|
||||||
|
r3 = f3.result(timeout=2)
|
||||||
|
|
||||||
|
total_elapsed = time.monotonic() - start_t
|
||||||
|
|
||||||
|
# 3 calls taking 0.2s each running in parallel should take ~0.2-0.35s total, NOT 0.6s+
|
||||||
|
self.assertLess(total_elapsed, 0.55, f"Execution was serialized instead of parallel: {total_elapsed:.3f}s")
|
||||||
|
self.assertEqual(r1["choices"][0]["message"]["content"], "output from ag-w1")
|
||||||
|
self.assertEqual(r2["choices"][0]["message"]["content"], "output from ag-w2")
|
||||||
|
self.assertEqual(r3["choices"][0]["message"]["content"], "output from ag-w3")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
398
tests/test_a37_isolation_guards.py
Normal file
398
tests/test_a37_isolation_guards.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""Test suite for Task A37: Agent Isolation, Credential Protection, Workspace Boundaries, and Forensic Audit."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from antigravity_provider.router.security_guard import (
|
||||||
|
WorkspaceBoundaryGuard,
|
||||||
|
NetworkBoundaryGuard,
|
||||||
|
AgentWorkspaceGuard,
|
||||||
|
BoundaryViolationError,
|
||||||
|
NetworkBoundaryViolationError,
|
||||||
|
scrub_secrets,
|
||||||
|
scrub_string,
|
||||||
|
get_workspace_guard,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor, do_delete_credentials
|
||||||
|
from antigravity_provider.router.web.server import app
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-1: Workspace Boundary & Destructive Operations Guard Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkspaceBoundaryGuard:
|
||||||
|
"""Tests for workspace boundaries, traversal prevention, and credential defense."""
|
||||||
|
|
||||||
|
def test_deletion_outside_allowed_workspace_is_blocked(self, tmp_path):
|
||||||
|
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "allowed_workspace"])
|
||||||
|
(tmp_path / "allowed_workspace").mkdir(parents=True, exist_ok=True)
|
||||||
|
outside_file = tmp_path / "outside_workspace" / "sensitive_file.txt"
|
||||||
|
outside_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
outside_file.write_text("critical data", encoding="utf-8")
|
||||||
|
|
||||||
|
ok, reason, safe_alt = guard.validate_path(outside_file, operation="delete")
|
||||||
|
assert ok is False
|
||||||
|
assert "за пределами разрешённых рабочих областей" in reason
|
||||||
|
assert safe_alt is not None
|
||||||
|
|
||||||
|
with pytest.raises(BoundaryViolationError):
|
||||||
|
guard.safe_delete_file(outside_file)
|
||||||
|
|
||||||
|
def test_unconditional_protection_of_credential_pools_and_ssh(self, tmp_path, monkeypatch):
|
||||||
|
hermes_home = tmp_path / ".hermes"
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
|
|
||||||
|
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[hermes_home])
|
||||||
|
|
||||||
|
# 1. agy_profiles
|
||||||
|
agy_profile = hermes_home / "agy_profiles" / "ag-w1" / "auth.json"
|
||||||
|
agy_profile.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
agy_profile.write_text("token", encoding="utf-8")
|
||||||
|
|
||||||
|
ok, reason, alt = guard.validate_path(agy_profile, operation="delete")
|
||||||
|
assert ok is False
|
||||||
|
assert "защищённого каталога учётных данных" in reason or "защищённым" in reason
|
||||||
|
assert "delete_credentials" in alt
|
||||||
|
|
||||||
|
# 2. hub_settings.json
|
||||||
|
settings_p = hermes_home / "hub_settings.json"
|
||||||
|
settings_p.write_text("{}", encoding="utf-8")
|
||||||
|
ok, reason, _ = guard.validate_path(settings_p, operation="delete")
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
# 3. .ssh
|
||||||
|
fake_ssh = Path.home() / ".ssh" / "id_rsa"
|
||||||
|
ok, reason, _ = guard.validate_path(fake_ssh, operation="delete")
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
def test_path_traversal_and_symlink_bypass_prevention(self, tmp_path):
|
||||||
|
ws_dir = tmp_path / "workspace"
|
||||||
|
ws_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
secret_outside = tmp_path / "secret.txt"
|
||||||
|
secret_outside.write_text("secret", encoding="utf-8")
|
||||||
|
|
||||||
|
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[ws_dir])
|
||||||
|
|
||||||
|
# Relative path traversal attack: workspace/../../secret.txt
|
||||||
|
traversal_path = ws_dir / "subdir" / ".." / ".." / "secret.txt"
|
||||||
|
ok, reason, _ = guard.validate_path(traversal_path, operation="delete")
|
||||||
|
assert ok is False
|
||||||
|
assert "за пределами" in reason
|
||||||
|
|
||||||
|
# Symlink attack: symlink inside workspace pointing outside
|
||||||
|
link_inside = ws_dir / "link_to_outside"
|
||||||
|
try:
|
||||||
|
link_inside.symlink_to(secret_outside)
|
||||||
|
ok_symlink, reason_symlink, _ = guard.validate_path(link_inside, operation="delete")
|
||||||
|
assert ok_symlink is False
|
||||||
|
assert "за пределами" in reason_symlink
|
||||||
|
except OSError:
|
||||||
|
# On platforms without symlink privileges, skip symlink creation
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_shell_command_analysis_and_classification(self, tmp_path):
|
||||||
|
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "project"])
|
||||||
|
project_dir = tmp_path / "project"
|
||||||
|
project_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Dangerous rm -rf / or outside
|
||||||
|
ok, reason, _ = guard.validate_command("rm -rf /etc/passwd", cwd=project_dir)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
# 2. Windows del C:\Windows
|
||||||
|
ok, reason, _ = guard.validate_command("del /f /q C:\\Windows\\System32", cwd=project_dir)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
# 3. Safe rm within project
|
||||||
|
ok, reason, _ = guard.validate_command("rm temp_file.log", cwd=project_dir)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_dry_run_deletion_reports_metadata_without_deleting(self, tmp_path):
|
||||||
|
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path])
|
||||||
|
test_dir = tmp_path / "to_delete"
|
||||||
|
test_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file1 = test_dir / "file1.txt"
|
||||||
|
file2 = test_dir / "file2.txt"
|
||||||
|
file1.write_text("hello 1", encoding="utf-8")
|
||||||
|
file2.write_text("hello 222", encoding="utf-8")
|
||||||
|
|
||||||
|
res = guard.dry_run_deletion([test_dir])
|
||||||
|
assert res["dry_run"] is True
|
||||||
|
assert res["allowed"] is True
|
||||||
|
assert res["total_dirs"] >= 1
|
||||||
|
assert res["total_files"] == 0 # directory item
|
||||||
|
assert res["total_bytes"] == len("hello 1".encode()) + len("hello 222".encode())
|
||||||
|
assert file1.exists()
|
||||||
|
assert file2.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-2: Agent Isolation & Credential Separation Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentIsolation:
|
||||||
|
"""Tests for dedicated agent workspaces, role-scoped credentials, and actor attribution."""
|
||||||
|
|
||||||
|
def test_dedicated_agent_workspace_creation(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
ws_dev1 = AgentWorkspaceGuard.get_agent_workspace_dir("developer-1")
|
||||||
|
ws_rev = AgentWorkspaceGuard.get_agent_workspace_dir("code-reviewer")
|
||||||
|
|
||||||
|
assert ws_dev1.is_dir()
|
||||||
|
assert ws_rev.is_dir()
|
||||||
|
assert ws_dev1 != ws_rev
|
||||||
|
assert "agent-developer-1" in str(ws_dev1)
|
||||||
|
assert "agent-code-reviewer" in str(ws_rev)
|
||||||
|
|
||||||
|
def test_role_scoped_credential_isolation(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
env_dev1 = AgentWorkspaceGuard.build_agent_subprocess_env(
|
||||||
|
agent_id="developer-1",
|
||||||
|
role="coder",
|
||||||
|
assigned_profile_id="ag-w1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert env_dev1["HERMES_AGENT_ID"] == "developer-1"
|
||||||
|
assert env_dev1["HERMES_AGENT_ROLE"] == "coder"
|
||||||
|
assert "ag-w1" in env_dev1["HOME"]
|
||||||
|
assert "ag-w1" in env_dev1["USERPROFILE"]
|
||||||
|
|
||||||
|
# Ensure no cross-agent foreign API keys leak
|
||||||
|
assert "ANTHROPIC_API_KEY" not in env_dev1
|
||||||
|
assert "OPENAI_API_KEY" not in env_dev1
|
||||||
|
|
||||||
|
def test_actor_attribution_in_action_executor(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
EventLogService._instance = None
|
||||||
|
event_svc = EventLogService.get()
|
||||||
|
|
||||||
|
res = ActionExecutor.execute(
|
||||||
|
action="dry_run_delete",
|
||||||
|
data={"paths": [str(tmp_path)]},
|
||||||
|
actor="agent:developer-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["ok"] is True
|
||||||
|
events = event_svc.get_events(limit=5)
|
||||||
|
actor_events = [e for e in events if e.actor == "agent:developer-1"]
|
||||||
|
assert len(actor_events) > 0
|
||||||
|
assert actor_events[0].action == "dry_run_delete"
|
||||||
|
assert actor_events[0].outcome == "dry_run"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-3: Network Boundary Guard & CORS Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestNetworkBoundaryAndCORS:
|
||||||
|
"""Tests for outbound destination whitelist, strict CORS, and network HTTP disclosures."""
|
||||||
|
|
||||||
|
def test_outbound_destination_whitelist(self):
|
||||||
|
# Allowed AI providers & releases
|
||||||
|
assert NetworkBoundaryGuard.is_host_allowed("https://api.anthropic.com/v1/messages") is True
|
||||||
|
assert NetworkBoundaryGuard.is_host_allowed("https://generativelanguage.googleapis.com") is True
|
||||||
|
assert NetworkBoundaryGuard.is_host_allowed("https://api.github.com/repos/releases") is True
|
||||||
|
assert NetworkBoundaryGuard.is_host_allowed("http://127.0.0.1:11434/api/tags") is True
|
||||||
|
assert NetworkBoundaryGuard.is_host_allowed("http://localhost:8000/v1/models") is True
|
||||||
|
|
||||||
|
# Blocked external / rogue hosts
|
||||||
|
assert NetworkBoundaryGuard.is_host_allowed("https://evil-hacker.com/exfil") is False
|
||||||
|
assert NetworkBoundaryGuard.is_host_allowed("http://internal-artifactory.local:8081") is False
|
||||||
|
|
||||||
|
with pytest.raises(NetworkBoundaryViolationError):
|
||||||
|
NetworkBoundaryGuard.validate_outbound_url("http://internal-artifactory.local/coordination")
|
||||||
|
|
||||||
|
def test_cors_rejects_wildcard_with_credentials(self, client):
|
||||||
|
response = client.options(
|
||||||
|
"/api/snapshot",
|
||||||
|
headers={"Origin": "https://attacker.example.com", "Access-Control-Request-Method": "GET"},
|
||||||
|
)
|
||||||
|
# Should NOT return allow-origin: * or echo back attacker origin
|
||||||
|
allow_origin = response.headers.get("access-control-allow-origin")
|
||||||
|
assert allow_origin != "*"
|
||||||
|
assert allow_origin != "https://attacker.example.com"
|
||||||
|
|
||||||
|
def test_network_http_warning_on_external_bind(self, client, monkeypatch):
|
||||||
|
from antigravity_provider.router import settings_service
|
||||||
|
|
||||||
|
fake_settings = {
|
||||||
|
"web_api_host": "0.0.0.0",
|
||||||
|
"web_api_token": "secret_token_123",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(settings_service, "get_hub_settings", lambda: fake_settings)
|
||||||
|
|
||||||
|
res = client.get("/api/settings", headers={"X-Hub-Token": "secret_token_123"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert "network_security" in data
|
||||||
|
assert data["network_security"]["is_external_bind"] is True
|
||||||
|
assert "открытого HTTP" in data["network_security"]["warning"]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-4: Forensic Audit Logging & Secret Scrubbing Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestForensicAuditAndSecretScrubbing:
|
||||||
|
"""Tests for secret scrubbing and forensic traceability."""
|
||||||
|
|
||||||
|
def test_secret_scrubbing_removes_tokens_and_bearer_headers(self):
|
||||||
|
dirty = {
|
||||||
|
"user_prompt": "Please summarize this commit",
|
||||||
|
"auth_header": "Bearer sk-proj-1234567890abcdef123456",
|
||||||
|
"api_key": "secret_api_key_value",
|
||||||
|
"nested": {
|
||||||
|
"token": "ghp_12345678901234567890",
|
||||||
|
"safe_field": "visible value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
clean = scrub_secrets(dirty)
|
||||||
|
assert clean["api_key"] == "***"
|
||||||
|
assert clean["nested"]["token"] == "***"
|
||||||
|
assert clean["nested"]["safe_field"] == "visible value"
|
||||||
|
assert "sk-proj" not in clean["auth_header"]
|
||||||
|
|
||||||
|
def test_string_scrubber_masks_inline_secrets(self):
|
||||||
|
msg = "Request failed with error: Bearer ghp_abcdef1234567890 on host https://api.openai.com?access_token=sk-9876543210"
|
||||||
|
scrubbed = scrub_string(msg)
|
||||||
|
assert "ghp_abcdef" not in scrubbed
|
||||||
|
assert "sk-987654" not in scrubbed
|
||||||
|
assert "Bearer ***" in scrubbed
|
||||||
|
assert "access_token=***" in scrubbed
|
||||||
|
|
||||||
|
def test_audit_log_captures_actor_and_outcome(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
EventLogService._instance = None
|
||||||
|
svc = EventLogService.get()
|
||||||
|
|
||||||
|
svc.log(
|
||||||
|
category="account",
|
||||||
|
message="Удаление профиля grok-worker-1",
|
||||||
|
actor="reviewer:manual",
|
||||||
|
action="delete_credentials",
|
||||||
|
target_profile="grok-worker-1",
|
||||||
|
target_role="code-reviewer",
|
||||||
|
outcome="success",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
events = svc.get_events(limit=5)
|
||||||
|
assert len(events) >= 1
|
||||||
|
ev = events[0]
|
||||||
|
assert ev.actor == "reviewer:manual"
|
||||||
|
assert ev.action == "delete_credentials"
|
||||||
|
assert ev.target_profile == "grok-worker-1"
|
||||||
|
assert ev.target_role == "code-reviewer"
|
||||||
|
assert ev.outcome == "success"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# P0-5: Live Deletion Confirmation & Safe Operations Tests
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCredentialsDeletionGuard:
|
||||||
|
"""Tests for explicit credential deletion confirmation and dry-run."""
|
||||||
|
|
||||||
|
def test_delete_credentials_dry_run(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||||||
|
|
||||||
|
auth_p = get_profile_auth_path("grok", "grok-worker-1")
|
||||||
|
auth_p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
|
||||||
|
|
||||||
|
res = ActionExecutor.execute(
|
||||||
|
action="delete_credentials",
|
||||||
|
data={"provider": "grok", "profile_id": "grok-worker-1", "dry_run": True},
|
||||||
|
actor="reviewer:manual",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res.get("dry_run") is True
|
||||||
|
assert auth_p.is_file() # File MUST NOT be deleted during dry-run
|
||||||
|
|
||||||
|
def test_delete_credentials_unconfirmed_request(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||||||
|
|
||||||
|
auth_p = get_profile_auth_path("grok", "grok-worker-1")
|
||||||
|
auth_p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
|
||||||
|
|
||||||
|
res = ActionExecutor.execute(
|
||||||
|
action="delete_credentials",
|
||||||
|
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": False},
|
||||||
|
actor="agent:developer-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert res.get("confirmation_required") is True
|
||||||
|
assert auth_p.is_file() # File MUST NOT be deleted without confirmation
|
||||||
|
|
||||||
|
def test_delete_credentials_confirmed_success(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||||||
|
|
||||||
|
auth_p = get_profile_auth_path("grok", "grok-worker-1")
|
||||||
|
auth_p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
|
||||||
|
|
||||||
|
res = ActionExecutor.execute(
|
||||||
|
action="delete_credentials",
|
||||||
|
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": True},
|
||||||
|
actor="user:web",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert not auth_p.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
|
||||||
|
"""Команда с тильдой не должна обходить защиту каталогов учётных данных.
|
||||||
|
|
||||||
|
validate_command не раскрывал "~" и "$HOME" перед проверкой. Путь
|
||||||
|
"~/.hermes/agy_profiles" не считался абсолютным, склеивался с каталогом
|
||||||
|
проекта в путь с буквальным "~" внутри и признавался допустимым.
|
||||||
|
|
||||||
|
Измерено на реализации: "rm -rf ~/.hermes/agy_profiles" проходило, а та же
|
||||||
|
команда с абсолютным путём отклонялась. То есть самый естественный способ
|
||||||
|
написать опасную команду обходил защиту ровно в том месте, ради которого
|
||||||
|
она и делалась.
|
||||||
|
"""
|
||||||
|
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
|
||||||
|
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
guard = WorkspaceBoundaryGuard()
|
||||||
|
|
||||||
|
must_reject = [
|
||||||
|
"rm -rf ~/.hermes/agy_profiles",
|
||||||
|
"rm -rf ~/.ssh",
|
||||||
|
"rm -rf $HOME/.hermes",
|
||||||
|
]
|
||||||
|
for cmd in must_reject:
|
||||||
|
allowed, reason, _alt = guard.validate_command(cmd)
|
||||||
|
assert not allowed, f"команда с тильдой прошла мимо защиты: {cmd} ({reason})"
|
||||||
|
|
||||||
|
# Обычная работа внутри проекта не должна страдать.
|
||||||
|
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
|
||||||
|
assert allowed, "защита мешает штатной работе внутри проекта"
|
||||||
Loading…
Reference in a new issue